From 65b8e64082ab8d10e40934657f34a0bf7a0148f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Tue, 23 Jun 2026 13:10:27 -0400 Subject: [PATCH 1/3] feat(classifier): recognise yubikey-agent PIV SSH touches yubikey-agent is a standalone PIV ssh-agent. The requesting ssh client talks to it over a UNIX socket, so it is not an ancestor in the touch process tree and the generic SSH rule misses it; this dedicated rule names the agent directly. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + internal/classifier/rules/all.go | 1 + internal/classifier/rules/yubikeyagent.go | 31 +++++++++ .../classifier/rules/yubikeyagent_test.go | 64 +++++++++++++++++++ 4 files changed, 97 insertions(+) create mode 100644 internal/classifier/rules/yubikeyagent.go create mode 100644 internal/classifier/rules/yubikeyagent_test.go diff --git a/README.md b/README.md index b1d3a0a..6a802a4 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ Environment variables (prefix `WHENCE_`): | `git` | push, pull, fetch, clone, signed commit | | `gpg` / `gpg2` | sign, decrypt, encrypt, verify | | `ssh` / `scp` / `sftp` | authenticate | +| `yubikey-agent` | SSH authenticate (PIV) | | browsers | WebAuthn / passkey | Unrecognised callers show the raw process chain. diff --git a/internal/classifier/rules/all.go b/internal/classifier/rules/all.go index 74bdf43..a29abbb 100644 --- a/internal/classifier/rules/all.go +++ b/internal/classifier/rules/all.go @@ -16,5 +16,6 @@ func All() []classifier.Rule { GPG{}, Browser{}, SSH{}, + YubiKeyAgent{}, } } diff --git a/internal/classifier/rules/yubikeyagent.go b/internal/classifier/rules/yubikeyagent.go new file mode 100644 index 0000000..ff3a98f --- /dev/null +++ b/internal/classifier/rules/yubikeyagent.go @@ -0,0 +1,31 @@ +package rules + +import ( + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +// YubiKeyAgent matches yubikey-agent (github.com/FiloSottile/yubikey-agent), a +// standalone PIV ssh-agent. With touch-policy=always, every SSH authentication +// served by the agent triggers a touch. +// +// Design note: the requesting ssh client talks to yubikey-agent over a UNIX +// socket, so the ssh client is NOT an ancestor in this process tree — that is +// exactly why the generic SSH rule does not catch this case, and why this +// dedicated rule exists. The agent has no per-host context, so the Resource is +// static. +// +// See https://github.com/FiloSottile/yubikey-agent. +type YubiKeyAgent struct{} + +func (YubiKeyAgent) Match(tree []classifier.Process) (classifier.Classification, bool) { + idx, _, ok := classifier.FindFirst(tree, "yubikey-agent") + if !ok { + return classifier.Classification{}, false + } + return classifier.Classification{ + Tool: "yubikey-agent", + Action: "ssh authenticate", + Resource: "PIV SSH key", + Depth: idx, + }, true +} diff --git a/internal/classifier/rules/yubikeyagent_test.go b/internal/classifier/rules/yubikeyagent_test.go new file mode 100644 index 0000000..893ed1e --- /dev/null +++ b/internal/classifier/rules/yubikeyagent_test.go @@ -0,0 +1,64 @@ +package rules + +import ( + "testing" + + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +func TestYubiKeyAgent(t *testing.T) { + tests := []struct { + name string + tree []classifier.Process + wantOK bool + wantTool string + wantAction string + wantResource string + wantDepth int + }{ + { + name: "yubikey-agent serving an SSH authentication", + tree: []classifier.Process{ + {PID: 1, Comm: "systemd", Args: []string{"/usr/lib/systemd/systemd"}}, + {PID: 42, Comm: "yubikey-agent", Args: []string{"/usr/bin/yubikey-agent", "-l", "/run/user/1000/yubikey-agent/yubikey-agent.sock"}}, + }, + wantOK: true, + wantTool: "yubikey-agent", + wantAction: "ssh authenticate", + wantResource: "PIV SSH key", + wantDepth: 1, + }, + { + name: "plain ssh must not match this rule", + tree: []classifier.Process{ + {PID: 1, Comm: "systemd", Args: []string{"/usr/lib/systemd/systemd"}}, + {PID: 99, Comm: "ssh", Args: []string{"ssh", "git@github.com"}}, + }, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := YubiKeyAgent{}.Match(tt.tree) + if ok != tt.wantOK { + t.Fatalf("Match() ok = %v, want %v", ok, tt.wantOK) + } + if !tt.wantOK { + return + } + if got.Tool != tt.wantTool { + t.Errorf("Tool = %q, want %q", got.Tool, tt.wantTool) + } + if got.Action != tt.wantAction { + t.Errorf("Action = %q, want %q", got.Action, tt.wantAction) + } + if got.Resource != tt.wantResource { + t.Errorf("Resource = %q, want %q", got.Resource, tt.wantResource) + } + if got.Depth != tt.wantDepth { + t.Errorf("Depth = %d, want %d", got.Depth, tt.wantDepth) + } + }) + } +} From 1659f82cb7920ab8287ad393794bf6ff492c00ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Thu, 25 Jun 2026 10:33:32 -0400 Subject: [PATCH 2/3] e2e: drive a yubikey-agent SSH signing touch Add an e2e check that starts yubikey-agent on a scratch socket and runs `ssh-add -T` against the PIV key it serves (the measured touch), asserting the classifier named `yubikey-agent`; the agent is killed after. Skips when yubikey-agent is absent or unprovisioned. Register it in the driver and document it. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/README.md | 1 + e2e/run.sh | 40 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/e2e/README.md b/e2e/README.md index ed33d27..7df3eee 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -43,6 +43,7 @@ a PASS / FAIL / SKIP matrix and exits non-zero if anything failed. | `ssh` | `ssh-keygen -t ed25519-sk` | FIDO2 PIN set on the key | | `age` | `age -d` via `age-plugin-yubikey` | PIV identity (best-effort) | | browser | opens webauthn.io in your default browser | a passkey/WebAuthn credential | +| `yubikey-agent` | `ssh-add -T` against a yubikey-agent socket | yubikey-agent set up (`--setup`) with touch policy | The watcher is granted only the eBPF caps (`cap_bpf`, `cap_perfmon`, `cap_sys_admin`). An agent-mediated touch (gpg, pass, sops, …) is attributed to diff --git a/e2e/run.sh b/e2e/run.sh index 242e9ab..5447df3 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -309,8 +309,46 @@ test_browser() { show_stack } +# yubikey-agent is a standalone PIV ssh-agent: it serves one SSH key from the +# YubiKey PIV slot, and with touch-policy=always every signature it makes needs +# a touch. We start it on a scratch socket and run `ssh-add -T` (sign+verify) +# against the key it serves — that signing op is the measured touch. The agent +# is killed on every exit path. The hyphenated function name is fine: bash +# defines it and the driver reaches it via the "test_$t" indirection. +test_yubikey-agent() { + command -v yubikey-agent >/dev/null || { record yubikey-agent SKIP "yubikey-agent not installed"; return; } + command -v ssh-add >/dev/null || { record yubikey-agent SKIP "ssh-add not installed"; return; } + ask_run "yubikey-agent — sign with the PIV ssh key it serves" || { record yubikey-agent SKIP "skipped"; return; } + local sock="$WORK/yubikey-agent.sock" + yubikey-agent -l "$sock" >"$WORK/ya.log" 2>&1 & local ya_pid=$! + local i + for i in $(seq 1 20); do + [ -S "$sock" ] && break + kill -0 "$ya_pid" 2>/dev/null || break + sleep 0.25 + done + if [ ! -S "$sock" ] || ! kill -0 "$ya_pid" 2>/dev/null; then + record yubikey-agent SKIP "agent failed to start (run 'yubikey-agent --setup' first? see $WORK/ya.log)" + kill "$ya_pid" 2>/dev/null + return + fi + SSH_AUTH_SOCK="$sock" ssh-add -L >"$WORK/ya.pub" 2>>"$WORK/ya.log" + if [ ! -s "$WORK/ya.pub" ]; then + record yubikey-agent SKIP "agent served no key" + kill "$ya_pid" 2>/dev/null + return + fi + touch_now "enter your PIV PIN if prompted"; mark + if SSH_AUTH_SOCK="$sock" timeout "$TOUCH_TIMEOUT" ssh-add -T "$WORK/ya.pub" >>"$WORK/ya.log" 2>&1; then + finish yubikey-agent yubikey-agent + else + record yubikey-agent FAIL "ssh-add -T failed/timed out (see $WORK/ya.log)" + fi + kill "$ya_pid" 2>/dev/null +} + # --- driver ------------------------------------------------------------------- -ALL=(gpg pass gopass sops git ssh age browser) +ALL=(gpg pass gopass sops git ssh age browser yubikey-agent) if [ "$#" -gt 0 ]; then SELECTED=("$@"); else SELECTED=("${ALL[@]}"); fi say "Testing: ${SELECTED[*]}" From 67040c86a750646ed87eab4bf04d7846c06b22e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Thu, 25 Jun 2026 12:39:04 -0400 Subject: [PATCH 3/3] nix: provide yubikey-agent in the e2e dev shell The yubikey-agent e2e test starts `yubikey-agent`; add it so it lands on PATH in `nix develop`. Co-Authored-By: Claude Opus 4.8 (1M context) --- flake.nix | 1 + 1 file changed, 1 insertion(+) diff --git a/flake.nix b/flake.nix index f9266c3..baae5ea 100644 --- a/flake.nix +++ b/flake.nix @@ -39,6 +39,7 @@ pkgs.yubikey-manager # ykman (key diagnostics) pkgs.age-plugin-yubikey # age + YubiKey via PIV pkgs.libfido2 # fido2-token etc. for FIDO diagnostics + pkgs.yubikey-agent # yubikey-agent (standalone PIV ssh-agent) pkgs.xdg-utils # xdg-open — launch the browser for the WebAuthn test ]; in