From 7d1f34b662ebf01af36e845fbef930b3d4a0d451 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Tue, 23 Jun 2026 13:09:33 -0400 Subject: [PATCH 1/3] feat(classifier): recognise KeePassXC YubiKey touches KeePassXC can protect a .kdbx database with a YubiKey/OnlyKey challenge-response (HMAC-SHA1) secondary key. When that slot requires a touch, the key blinks during unlock; name the responsible tool. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + internal/classifier/rules/all.go | 1 + internal/classifier/rules/keepassxc.go | 40 ++++++++ internal/classifier/rules/keepassxc_test.go | 103 ++++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 internal/classifier/rules/keepassxc.go create mode 100644 internal/classifier/rules/keepassxc_test.go diff --git a/README.md b/README.md index b1d3a0a..98e3710 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Environment variables (prefix `WHENCE_`): | Tool | Operations | |---|---| | `pass` | show, insert, generate, edit | +| `keepassxc` | unlock (YubiKey challenge-response) | | `sops` | encrypt, decrypt, edit, rotate | | `age` / `rage` | encrypt, decrypt | | `git` | push, pull, fetch, clone, signed commit | diff --git a/internal/classifier/rules/all.go b/internal/classifier/rules/all.go index 74bdf43..b5c9ed7 100644 --- a/internal/classifier/rules/all.go +++ b/internal/classifier/rules/all.go @@ -11,6 +11,7 @@ func All() []classifier.Rule { SOPS{}, Gopass{}, Pass{}, + KeePassXC{}, Age{}, Git{}, GPG{}, diff --git a/internal/classifier/rules/keepassxc.go b/internal/classifier/rules/keepassxc.go new file mode 100644 index 0000000..6731f16 --- /dev/null +++ b/internal/classifier/rules/keepassxc.go @@ -0,0 +1,40 @@ +package rules + +import ( + "strings" + + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +// KeePassXC matches KeePassXC (https://keepassxc.org/) database unlocks. +// A .kdbx database can be protected by a YubiKey/OnlyKey challenge-response +// (HMAC-SHA1) secondary key. When that slot is configured with "require +// touch", the key blinks and waits for a touch while the database is unlocked. +type KeePassXC struct{} + +func (KeePassXC) Match(tree []classifier.Process) (classifier.Classification, bool) { + idx, p, ok := classifier.FindFirst(tree, "keepassxc", "keepassxc-cli") + if !ok { + return classifier.Classification{}, false + } + return classifier.Classification{ + Tool: "keepassxc", + Action: "unlock", + Resource: keepassxcResource(p), + Depth: idx, + }, true +} + +// keepassxcResource returns the database path: the first .kdbx argument when +// present, otherwise the first positional argument, otherwise "database". +func keepassxcResource(p classifier.Process) string { + for _, arg := range p.Args { + if strings.HasSuffix(arg, ".kdbx") { + return arg + } + } + if pos := classifier.FirstPositional(p); pos != "" { + return pos + } + return "database" +} diff --git a/internal/classifier/rules/keepassxc_test.go b/internal/classifier/rules/keepassxc_test.go new file mode 100644 index 0000000..0efd6f3 --- /dev/null +++ b/internal/classifier/rules/keepassxc_test.go @@ -0,0 +1,103 @@ +package rules + +import ( + "testing" + + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +// proc builds a Process with both Comm and Args set so Name() resolves to the +// argv[0] basename while Comm still satisfies the kernel-comm fallback. +func proc(comm string, args ...string) classifier.Process { + return classifier.Process{Comm: comm, Args: args} +} + +func TestKeePassXCMatch(t *testing.T) { + cases := []struct { + name string + tree []classifier.Process + wantOK bool + wantTool string + wantAction string + wantResource string + wantDepth int + }{ + { + name: "kdbx path resolves as resource", + tree: []classifier.Process{ + proc("bash", "bash"), + proc("keepassxc", "keepassxc", "/home/me/secrets.kdbx"), + }, + wantOK: true, + wantTool: "keepassxc", + wantAction: "unlock", + wantResource: "/home/me/secrets.kdbx", + wantDepth: 1, + }, + { + name: "keepassxc-cli matches and prefers kdbx over other positionals", + tree: []classifier.Process{ + proc("keepassxc-cli", "keepassxc-cli", "open", "/vault/db.kdbx"), + }, + wantOK: true, + wantTool: "keepassxc", + wantAction: "unlock", + wantResource: "/vault/db.kdbx", + wantDepth: 0, + }, + { + name: "no kdbx falls back to first positional", + tree: []classifier.Process{ + proc("keepassxc-cli", "keepassxc-cli", "show", "Email"), + }, + wantOK: true, + wantTool: "keepassxc", + wantAction: "unlock", + wantResource: "show", + wantDepth: 0, + }, + { + name: "no positional falls back to database", + tree: []classifier.Process{ + proc("keepassxc", "keepassxc"), + }, + wantOK: true, + wantTool: "keepassxc", + wantAction: "unlock", + wantResource: "database", + wantDepth: 0, + }, + { + name: "tree without keepassxc does not match", + tree: []classifier.Process{ + proc("bash", "bash"), + proc("ssh", "ssh", "host"), + }, + wantOK: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := KeePassXC{}.Match(tc.tree) + if ok != tc.wantOK { + t.Fatalf("Match ok = %v, want %v", ok, tc.wantOK) + } + if !tc.wantOK { + return + } + if got.Tool != tc.wantTool { + t.Errorf("Tool = %q, want %q", got.Tool, tc.wantTool) + } + if got.Action != tc.wantAction { + t.Errorf("Action = %q, want %q", got.Action, tc.wantAction) + } + if got.Resource != tc.wantResource { + t.Errorf("Resource = %q, want %q", got.Resource, tc.wantResource) + } + if got.Depth != tc.wantDepth { + t.Errorf("Depth = %d, want %d", got.Depth, tc.wantDepth) + } + }) + } +} From 3bbbe8216e3e70bfb6012229e93cfad02114227e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Graveline?= Date: Thu, 25 Jun 2026 10:31:19 -0400 Subject: [PATCH 2/3] e2e: drive a KeePassXC challenge-response unlock Add an e2e check that creates an ephemeral .kdbx bound to the YubiKey challenge-response slot and opens it with keepassxc-cli, asserting the classifier named `keepassxc`. Skips when keepassxc-cli 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 | 22 +++++++++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/e2e/README.md b/e2e/README.md index ed33d27..c525a9d 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 | +| `keepassxc` | `keepassxc-cli` open a .kdbx | YubiKey challenge-response slot (touch) | 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..8779fe8 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -286,6 +286,26 @@ test_age() { fi } +test_keepassxc() { + command -v keepassxc-cli >/dev/null || { record keepassxc SKIP "keepassxc-cli not installed"; return; } + ask_run "keepassxc — open a .kdbx secured by your YubiKey challenge-response (slot 2)" || { record keepassxc SKIP "skipped"; return; } + # Setup (counts as setup touches, like gopass): create an ephemeral database + # protected by a password AND a YubiKey HMAC-SHA1 challenge-response key on + # slot 2 (-y 2). Adding the challenge-response key during db-create blinks the + # key for a touch — that is a SETUP touch, not the measured one. + printf 'e2e-pw\ne2e-pw\n' | keepassxc-cli db-create -p -y 2 "$WORK/e2e.kdbx" >"$WORK/kpxc.log" 2>&1 || + { record keepassxc SKIP "db-create failed (YubiKey challenge-response slot configured? see $WORK/kpxc.log)"; return; } + say "the upcoming OPEN (keepassxc-cli ls) is the measured touch" + touch_now; mark + # Measured op: `ls` lists entries, which unlocks the database — the password is + # read from stdin and the challenge-response slot blinks for the touch. + if printf 'e2e-pw\n' | timeout "$TOUCH_TIMEOUT" keepassxc-cli ls -y 2 "$WORK/e2e.kdbx" >>"$WORK/kpxc.log" 2>&1; then + finish keepassxc keepassxc + else + record keepassxc FAIL "keepassxc-cli open failed/timed out (see $WORK/kpxc.log)" + fi +} + test_browser() { if [ ! -t 0 ]; then record browser SKIP "manual test needs a TTY"; return; fi ask_run "browser — WebAuthn/passkey at webauthn.io (opens your default browser)" || { record browser SKIP "skipped"; return; } @@ -310,7 +330,7 @@ test_browser() { } # --- driver ------------------------------------------------------------------- -ALL=(gpg pass gopass sops git ssh age browser) +ALL=(gpg pass gopass sops git ssh age browser keepassxc) if [ "$#" -gt 0 ]; then SELECTED=("$@"); else SELECTED=("${ALL[@]}"); fi say "Testing: ${SELECTED[*]}" From 74e5cb0a26d9bf03b20edd848eee805bdb30f927 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 keepassxc (keepassxc-cli) in the e2e dev shell The keepassxc e2e test drives `keepassxc-cli`; add `keepassxc` so both the CLI and GUI land 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..fb91501 100644 --- a/flake.nix +++ b/flake.nix @@ -36,6 +36,7 @@ pkgs.age # age pkgs.rage # rage pkgs.git # git + pkgs.keepassxc # keepassxc / keepassxc-cli pkgs.yubikey-manager # ykman (key diagnostics) pkgs.age-plugin-yubikey # age + YubiKey via PIV pkgs.libfido2 # fido2-token etc. for FIDO diagnostics