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 @@ -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.

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 |
| `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
Expand Down
20 changes: 19 additions & 1 deletion e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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[*]}"
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{},
Ykman{},
}
}
126 changes: 126 additions & 0 deletions internal/classifier/rules/ykman.go
Original file line number Diff line number Diff line change
@@ -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 <name>" — 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 ""
}
87 changes: 87 additions & 0 deletions internal/classifier/rules/ykman_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}