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 @@ -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 |
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 |
| `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
Expand Down
22 changes: 21 additions & 1 deletion e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand All @@ -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[*]}"
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 @@ -11,6 +11,7 @@ func All() []classifier.Rule {
SOPS{},
Gopass{},
Pass{},
KeePassXC{},
Age{},
Git{},
GPG{},
Expand Down
40 changes: 40 additions & 0 deletions internal/classifier/rules/keepassxc.go
Original file line number Diff line number Diff line change
@@ -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"
}
103 changes: 103 additions & 0 deletions internal/classifier/rules/keepassxc_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}