Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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[*]}"
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/classifier/rules/all.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,6 @@ func All() []classifier.Rule {
GPG{},
Browser{},
SSH{},
YubiKeyAgent{},
}
}
31 changes: 31 additions & 0 deletions internal/classifier/rules/yubikeyagent.go
Original file line number Diff line number Diff line change
@@ -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
}
64 changes: 64 additions & 0 deletions internal/classifier/rules/yubikeyagent_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}