diff --git a/README.md b/README.md index b1d3a0a..03ecd6b 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ Environment variables (prefix `WHENCE_`): | Tool | Operations | |---|---| | `pass` | show, insert, generate, edit | +| `1password` / `op` | authenticate (security-key 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..d5100a7 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 | +| `1password` | manual: unlock / `op signin` with your security key | a 1Password security-key (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..7544698 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -309,8 +309,20 @@ test_browser() { show_stack } +test_1password() { + if [ ! -t 0 ]; then record 1password SKIP "manual test needs a TTY"; return; fi + ask_run "1password — unlock / sign in with your security key (desktop app or 'op')" || { record 1password SKIP "skipped"; return; } + command -v op >/dev/null 2>&1 || say "('op' CLI not found — use the 1Password desktop app instead)" + say "In 1Password, do something that prompts for your YubiKey — unlock with a" + say "security key, or 'op signin' on an account whose 2FA is a security key —" + say "and touch the key when it blinks." + mark + read -r -p " press Enter once the 1Password touch is done… " _ + finish 1password 1password +} + # --- driver ------------------------------------------------------------------- -ALL=(gpg pass gopass sops git ssh age browser) +ALL=(gpg pass gopass sops git ssh age browser 1password) if [ "$#" -gt 0 ]; then SELECTED=("$@"); else SELECTED=("${ALL[@]}"); fi say "Testing: ${SELECTED[*]}" diff --git a/flake.nix b/flake.nix index f9266c3..39091d7 100644 --- a/flake.nix +++ b/flake.nix @@ -7,7 +7,9 @@ let # eBPF (and this tool) are Linux-only. systems = [ "x86_64-linux" "aarch64-linux" ]; - forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system}); + # 1Password's CLI (`op`) is unfree; allow it so the e2e shell can run it. + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: + f (import nixpkgs { inherit system; config.allowUnfree = true; })); in { # `nix develop` drops you into a shell that can BUILD the tool and RUN @@ -36,6 +38,7 @@ pkgs.age # age pkgs.rage # rage pkgs.git # git + pkgs._1password-cli # op (1Password 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..9f42e8c 100644 --- a/internal/classifier/rules/all.go +++ b/internal/classifier/rules/all.go @@ -11,6 +11,7 @@ func All() []classifier.Rule { SOPS{}, Gopass{}, Pass{}, + OnePassword{}, Age{}, Git{}, GPG{}, diff --git a/internal/classifier/rules/onepassword.go b/internal/classifier/rules/onepassword.go new file mode 100644 index 0000000..412e665 --- /dev/null +++ b/internal/classifier/rules/onepassword.go @@ -0,0 +1,28 @@ +package rules + +import ( + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +// OnePassword matches 1Password (https://1password.com/) touch requests from +// either the desktop app or the "op" CLI. +// +// On Linux the security key is most often used as account 2FA in the browser, +// which the Browser rule already names; this rule names the touch when the +// 1Password desktop app or the `op` CLI is the toucher instead. +type OnePassword struct{} + +func (OnePassword) Match(tree []classifier.Process) (classifier.Classification, bool) { + // "op" is the 1Password CLI and is a short/generic name; a rule only fires + // inside a confirmed YubiKey-touch tree, so false positives are unlikely. + idx, _, ok := classifier.FindFirst(tree, "1password", "1Password", "op") + if !ok { + return classifier.Classification{}, false + } + return classifier.Classification{ + Tool: "1password", + Action: "authenticate", + Resource: "1Password", + Depth: idx, + }, true +} diff --git a/internal/classifier/rules/onepassword_test.go b/internal/classifier/rules/onepassword_test.go new file mode 100644 index 0000000..f81833d --- /dev/null +++ b/internal/classifier/rules/onepassword_test.go @@ -0,0 +1,76 @@ +package rules + +import ( + "testing" + + "github.com/Talgarr/Whence-Touche/internal/classifier" +) + +func TestOnePasswordMatch(t *testing.T) { + cases := []struct { + name string + tree []classifier.Process + wantTool string + wantAction string + wantResource string + wantDepth int + wantOK bool + }{ + { + name: "1password desktop app matches", + tree: []classifier.Process{ + {PID: 1, Comm: "systemd", Args: []string{"/sbin/init"}}, + {PID: 2, Comm: "1password", Args: []string{"/opt/1Password/1password"}}, + }, + wantTool: "1password", + wantAction: "authenticate", + wantResource: "1Password", + wantDepth: 1, + wantOK: true, + }, + { + name: "op CLI matches", + tree: []classifier.Process{ + {PID: 1, Comm: "bash", Args: []string{"bash"}}, + {PID: 2, Comm: "op", Args: []string{"op", "item", "get", "GitHub"}}, + }, + wantTool: "1password", + wantAction: "authenticate", + wantResource: "1Password", + wantDepth: 1, + wantOK: true, + }, + { + name: "no match returns false", + tree: []classifier.Process{ + {PID: 1, Comm: "bash", Args: []string{"bash"}}, + {PID: 2, Comm: "gpg", Args: []string{"gpg", "--sign"}}, + }, + wantOK: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, ok := OnePassword{}.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) + } + }) + } +}