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 |
| `1password` / `op` | authenticate (security-key 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 |
| `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
Expand Down
14 changes: 13 additions & 1 deletion e2e/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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[*]}"
Expand Down
5 changes: 4 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
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{},
OnePassword{},
Age{},
Git{},
GPG{},
Expand Down
28 changes: 28 additions & 0 deletions internal/classifier/rules/onepassword.go
Original file line number Diff line number Diff line change
@@ -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
}
76 changes: 76 additions & 0 deletions internal/classifier/rules/onepassword_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}