diff --git a/README.md b/README.md index b1d3a0a..02c4489 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/e2e/README.md b/e2e/README.md index ed33d27..bc55604 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 | +| `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 diff --git a/e2e/run.sh b/e2e/run.sh index 242e9ab..fd367e5 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -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[*]}" diff --git a/flake.nix b/flake.nix index f9266c3..5fc8019 100644 --- a/flake.nix +++ b/flake.nix @@ -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 diff --git a/internal/classifier/rules/all.go b/internal/classifier/rules/all.go index 74bdf43..de9e5a6 100644 --- a/internal/classifier/rules/all.go +++ b/internal/classifier/rules/all.go @@ -11,6 +11,7 @@ func All() []classifier.Rule { SOPS{}, Gopass{}, Pass{}, + Bitwarden{}, Age{}, Git{}, GPG{}, diff --git a/internal/classifier/rules/bitwarden.go b/internal/classifier/rules/bitwarden.go new file mode 100644 index 0000000..b1a1018 --- /dev/null +++ b/internal/classifier/rules/bitwarden.go @@ -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 +} diff --git a/internal/classifier/rules/bitwarden_test.go b/internal/classifier/rules/bitwarden_test.go new file mode 100644 index 0000000..17b56db --- /dev/null +++ b/internal/classifier/rules/bitwarden_test.go @@ -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) + } + }) + } +}