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 |
| `bitwarden` / `bw` | authenticate (YubiKey OTP 2FA) |
| `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 |
| `bitwarden` | manual: unlock / login using YubiKey OTP 2FA | a Bitwarden account with YubiKey OTP 2FA |

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
13 changes: 12 additions & 1 deletion e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -309,8 +309,19 @@ test_browser() {
show_stack
}

test_bitwarden() {
if [ ! -t 0 ]; then record bitwarden SKIP "manual test needs a TTY"; return; fi
ask_run "bitwarden — unlock / log in using your YubiKey OTP 2FA (desktop app or 'bw')" || { record bitwarden SKIP "skipped"; return; }
command -v bw >/dev/null 2>&1 || say "('bw' CLI not found — use the Bitwarden desktop app instead)"
say "In Bitwarden, do an unlock/login that uses the YubiKey OTP 2FA method"
say "(desktop app or 'bw login --method 0') and touch the key to emit the code."
mark
read -r -p " press Enter once the Bitwarden touch is done… " _
finish bitwarden bitwarden
}

# --- driver -------------------------------------------------------------------
ALL=(gpg pass gopass sops git ssh age browser)
ALL=(gpg pass gopass sops git ssh age browser bitwarden)
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.bitwarden-cli # bw (Bitwarden 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{},
Bitwarden{},
Age{},
Git{},
GPG{},
Expand Down
27 changes: 27 additions & 0 deletions internal/classifier/rules/bitwarden.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package rules

import (
"github.com/Talgarr/Whence-Touche/internal/classifier"
)

// Bitwarden matches Bitwarden (https://bitwarden.com/) vault unlocks that
// touch the key. Bitwarden offers a YubiKey OTP two-factor method: both the
// desktop app (the `bitwarden` Electron binary) and the `bw` CLI prompt for
// the YubiKey OTP, and the key emits the one-time code on touch (HID OTP).
// (WebAuthn/passkey unlock goes through the browser and is out of scope here.)
type Bitwarden struct{}

func (Bitwarden) Match(tree []classifier.Process) (classifier.Classification, bool) {
// "bw" is the CLI and is a short name, but a rule only fires inside a
// confirmed YubiKey-touch process tree, so false positives are unlikely.
idx, _, ok := classifier.FindFirst(tree, "bitwarden", "bw")
if !ok {
return classifier.Classification{}, false
}
return classifier.Classification{
Tool: "bitwarden",
Action: "authenticate",
Resource: "Bitwarden vault",
Depth: idx,
}, true
}
67 changes: 67 additions & 0 deletions internal/classifier/rules/bitwarden_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package rules

import (
"testing"

"github.com/Talgarr/Whence-Touche/internal/classifier"
)

func TestBitwardenMatch(t *testing.T) {
cases := []struct {
name string
tree []classifier.Process
wantOK bool
wantDepth int
}{
{
name: "desktop bitwarden matches",
tree: []classifier.Process{
{PID: 1, Comm: "systemd", Args: []string{"/usr/lib/systemd/systemd"}},
{PID: 2, Comm: "bitwarden", Args: []string{"/opt/Bitwarden/bitwarden"}},
},
wantOK: true,
wantDepth: 1,
},
{
name: "bw CLI matches",
tree: []classifier.Process{
{PID: 1, Comm: "bash", Args: []string{"/bin/bash"}},
{PID: 2, Comm: "bw", Args: []string{"bw", "unlock"}},
},
wantOK: true,
wantDepth: 1,
},
{
name: "tree without bitwarden returns false",
tree: []classifier.Process{
{PID: 1, Comm: "bash", Args: []string{"/bin/bash"}},
{PID: 2, Comm: "ssh", Args: []string{"ssh", "host"}},
},
wantOK: false,
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := Bitwarden{}.Match(tc.tree)
if ok != tc.wantOK {
t.Fatalf("Match() ok = %v, want %v", ok, tc.wantOK)
}
if !ok {
return
}
if got.Tool != "bitwarden" {
t.Errorf("Tool = %q, want %q", got.Tool, "bitwarden")
}
if got.Action != "authenticate" {
t.Errorf("Action = %q, want %q", got.Action, "authenticate")
}
if got.Resource != "Bitwarden vault" {
t.Errorf("Resource = %q, want %q", got.Resource, "Bitwarden vault")
}
if got.Depth != tc.wantDepth {
t.Errorf("Depth = %d, want %d", got.Depth, tc.wantDepth)
}
})
}
}