From 546fd49f832a42d79b0d5b670b56ec774cef2aba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Tue, 23 Jun 2026 13:10:21 -0400 Subject: [PATCH 1/2] feat(classifier): recognise ykman / Yubico Authenticator touches OATH TOTP/HOTP codes that require touch blink the key on generation; ykman's PIV, FIDO and OpenPGP subcommands can also require a touch. Adds a rule matching the ykman CLI and the Yubico Authenticator GUI, parsing the CLI subcommand to report OATH code, PIV, FIDO, OpenPGP, OTP or management actions. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + internal/classifier/rules/all.go | 1 + internal/classifier/rules/ykman.go | 126 ++++++++++++++++++++++++ internal/classifier/rules/ykman_test.go | 87 ++++++++++++++++ 4 files changed, 215 insertions(+) create mode 100644 internal/classifier/rules/ykman.go create mode 100644 internal/classifier/rules/ykman_test.go diff --git a/README.md b/README.md index b1d3a0a..974f458 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Environment variables (prefix `WHENCE_`): | `gpg` / `gpg2` | sign, decrypt, encrypt, verify | | `ssh` / `scp` / `sftp` | authenticate | | browsers | WebAuthn / passkey | +| `ykman` / Yubico Authenticator | OATH code, PIV, FIDO, management | Unrecognised callers show the raw process chain. diff --git a/internal/classifier/rules/all.go b/internal/classifier/rules/all.go index 74bdf43..1cd800c 100644 --- a/internal/classifier/rules/all.go +++ b/internal/classifier/rules/all.go @@ -16,5 +16,6 @@ func All() []classifier.Rule { GPG{}, Browser{}, SSH{}, + Ykman{}, } } diff --git a/internal/classifier/rules/ykman.go b/internal/classifier/rules/ykman.go new file mode 100644 index 0000000..caa20cf --- /dev/null +++ b/internal/classifier/rules/ykman.go @@ -0,0 +1,126 @@ +package rules + +import ( + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +// Ykman matches the YubiKey Manager CLI (ykman) and the Yubico Authenticator +// GUI. OATH TOTP/HOTP credentials configured to require touch blink when a +// code is generated; ykman's PIV/FIDO/OpenPGP subcommands can likewise require +// a touch to authorise the operation. +// +// See https://github.com/Yubico/yubikey-manager and +// https://github.com/Yubico/yubioath-flutter. +type Ykman struct{} + +func (Ykman) Match(tree []classifier.Process) (classifier.Classification, bool) { + // "authenticator" is the binary name of the modern Yubico Authenticator + // app; it is somewhat generic, but a rule only fires inside a confirmed + // YubiKey-touch tree. + idx, p, ok := classifier.FindFirst(tree, + "ykman", "yubikey-manager", + "yubico-authenticator", "authenticator", + "yubioath-desktop", "yubioath", + ) + if !ok { + return classifier.Classification{}, false + } + + tool := "yubico-authenticator" + switch p.Name() { + case "ykman", "yubikey-manager": + tool = "ykman" + } + if p.Comm == "ykman" || p.Comm == "yubikey-manager" { + tool = "ykman" + } + + action, resource := ykmanOperation(tool, p) + return classifier.Classification{ + Tool: tool, + Action: action, + Resource: resource, + Depth: idx, + }, true +} + +func ykmanOperation(tool string, p classifier.Process) (action, resource string) { + // The GUI apps don't expose a useful command line; they generate OATH + // codes, so report a sensible default. + if tool != "ykman" { + return "OATH code", "TOTP" + } + + sub, words := parseYkmanArgs(p) + resource = "YubiKey" + + switch sub { + case "oath": + action = "OATH code" + // "ykman oath accounts code " — the credential name follows the + // "code" token. + if name := wordAfter(words, "code"); name != "" { + resource = name + } else { + resource = "TOTP" + } + case "piv": + action = "PIV" + if verb := firstOf(words, "sign", "generate", "import"); verb != "" { + action = "PIV " + verb + } + case "fido": + action = "FIDO" + case "openpgp": + action = "OpenPGP" + case "otp": + action = "OTP" + default: + action = "manage" + } + return action, resource +} + +// parseYkmanArgs returns the first subcommand among the recognised set and the +// remaining positional words that follow it (flags stripped). +func parseYkmanArgs(p classifier.Process) (sub string, words []string) { + subcommands := map[string]bool{ + "oath": true, "piv": true, "fido": true, + "openpgp": true, "otp": true, "config": true, + } + for _, arg := range p.Args[1:] { + if len(arg) > 0 && arg[0] == '-' { + continue + } + if sub == "" { + if subcommands[arg] { + sub = arg + } + continue + } + words = append(words, arg) + } + return sub, words +} + +// wordAfter returns the word immediately following key in words, or "". +func wordAfter(words []string, key string) string { + for i, w := range words { + if w == key && i+1 < len(words) { + return words[i+1] + } + } + return "" +} + +// firstOf returns the first of candidates that appears in words, or "". +func firstOf(words []string, candidates ...string) string { + for _, w := range words { + for _, c := range candidates { + if w == c { + return w + } + } + } + return "" +} diff --git a/internal/classifier/rules/ykman_test.go b/internal/classifier/rules/ykman_test.go new file mode 100644 index 0000000..b04a874 --- /dev/null +++ b/internal/classifier/rules/ykman_test.go @@ -0,0 +1,87 @@ +package rules + +import ( + "testing" + + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +func TestYkmanMatch(t *testing.T) { + tests := []struct { + name string + tree []classifier.Process + wantOK bool + wantTool string + wantAction string + wantResource string + wantDepth int + }{ + { + name: "oath code with account name", + tree: []classifier.Process{ + {PID: 1, Comm: "bash"}, + {PID: 2, Comm: "ykman", Args: []string{"ykman", "oath", "accounts", "code", "github"}}, + }, + wantOK: true, + wantTool: "ykman", + wantAction: "OATH code", + wantResource: "github", + wantDepth: 1, + }, + { + name: "piv keys sign", + tree: []classifier.Process{ + {PID: 1, Comm: "ykman", Args: []string{"ykman", "piv", "keys", "sign", "9a", "cert.pem"}}, + }, + wantOK: true, + wantTool: "ykman", + wantAction: "PIV sign", + wantResource: "YubiKey", + wantDepth: 0, + }, + { + name: "gui yubico-authenticator", + tree: []classifier.Process{ + {PID: 1, Comm: "systemd"}, + {PID: 2, Comm: "yubico-authent", Args: []string{"/usr/bin/yubico-authenticator"}}, + }, + wantOK: true, + wantTool: "yubico-authenticator", + wantAction: "OATH code", + wantResource: "TOTP", + wantDepth: 1, + }, + { + name: "no match", + tree: []classifier.Process{ + {PID: 1, Comm: "bash"}, + {PID: 2, Comm: "ssh", Args: []string{"ssh", "host"}}, + }, + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, ok := Ykman{}.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 4980c0ad2df428466a19fb8e9c9c1ab1afa33fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Thu, 25 Jun 2026 10:31:40 -0400 Subject: [PATCH 2/2] e2e: drive a ykman OATH touch Add an e2e check that adds an ephemeral touch-required OATH credential, generates its code with `ykman oath accounts code` (the measured touch), and asserts the classifier named `ykman`; the credential is removed after. Skips when ykman is absent. Register it in the driver and document it. Co-Authored-By: Claude Opus 4.8 (1M context) --- e2e/README.md | 1 + e2e/run.sh | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/e2e/README.md b/e2e/README.md index ed33d27..8b8a4b4 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 | +| `ykman` | `ykman oath accounts code` (touch-required cred) | a YubiKey with the OATH applet | 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..c0d99ee 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -309,8 +309,26 @@ test_browser() { show_stack } +test_ykman() { + command -v ykman >/dev/null || { record ykman SKIP "ykman not installed"; return; } + ask_run "ykman — generate a touch-required OATH code" || { record ykman SKIP "skipped"; return; } + # Add an ephemeral touch-required TOTP credential (secret must be valid base32). + ykman oath accounts add --touch whence-touche-e2e GEZDGNBVGY3TQOJQ -f >"$WORK/ykman.log" 2>&1 || + { record ykman SKIP "ykman oath add failed — OATH applet locked/unavailable? (see $WORK/ykman.log)"; return; } + touch_now; mark + # Generating the code is the measured touch. Delete the credential right after + # on both the PASS and FAIL paths so it never lingers on the key. + if timeout "$TOUCH_TIMEOUT" ykman oath accounts code whence-touche-e2e >>"$WORK/ykman.log" 2>&1; then + ykman oath accounts delete whence-touche-e2e -f >/dev/null 2>&1 + finish ykman ykman + else + ykman oath accounts delete whence-touche-e2e -f >/dev/null 2>&1 + record ykman FAIL "ykman oath code failed/timed out (touch policy enabled? see $WORK/ykman.log)" + fi +} + # --- driver ------------------------------------------------------------------- -ALL=(gpg pass gopass sops git ssh age browser) +ALL=(gpg pass gopass sops git ssh age browser ykman) if [ "$#" -gt 0 ]; then SELECTED=("$@"); else SELECTED=("${ALL[@]}"); fi say "Testing: ${SELECTED[*]}"