From 6c53ee10b16a5c8c2081435e04c54198ddea5f6d Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:14:24 +0000 Subject: [PATCH 01/13] feat(cli): register homelab vault command group skeleton --- cli/cmd_vault.go | 35 +++++++++++++++++++++++++++++++++++ cli/cmd_vault_test.go | 30 ++++++++++++++++++++++++++++++ cli/homelab.go | 1 + 3 files changed, 66 insertions(+) create mode 100644 cli/cmd_vault.go create mode 100644 cli/cmd_vault_test.go diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go new file mode 100644 index 00000000..fab86f52 --- /dev/null +++ b/cli/cmd_vault.go @@ -0,0 +1,35 @@ +package main + +import "fmt" + +// vault verbs give each unix user no-HITL access to THEIR OWN Vaultwarden vault. +// Identity is the kernel UID; per-user creds live in that user's isolated Vault +// path (secret/workstation/claude-users/) read via their scoped token, and +// decryption is done by the official `bw` CLI. See +// docs/superpowers/specs/2026-06-24-homelab-vault-design.md. +func vaultCommands() []Command { + return []Command{ + {Path: []string{"vault", "setup"}, Tier: TierWrite, + Summary: "one-time: store your Vaultwarden master password + API key in your Vault path", Run: vaultSetup}, + {Path: []string{"vault", "status"}, Tier: TierRead, + Summary: "show whether your vault is configured/reachable (no secrets)", Run: vaultStatus}, + {Path: []string{"vault", "list"}, Tier: TierRead, + Summary: "list your item names: vault list [--search Q]", Run: vaultList}, + {Path: []string{"vault", "get"}, Tier: TierRead, + Summary: "fetch one item: vault get [--field password|username|uri|notes] [--json]", Run: vaultGet}, + {Path: []string{"vault", "search"}, Tier: TierRead, + Summary: "search your item names: vault search ", Run: vaultSearch}, + {Path: []string{"vault", "code"}, Tier: TierRead, + Summary: "current TOTP code for an item: vault code ", Run: vaultCode}, + {Path: []string{"vault", "lock"}, Tier: TierWrite, + Summary: "lock/log out the local bw session", Run: vaultLock}, + } +} + +func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } +func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } +func vaultList(args []string) error { return fmt.Errorf("not implemented") } +func vaultGet(args []string) error { return fmt.Errorf("not implemented") } +func vaultSearch(args []string) error { return fmt.Errorf("not implemented") } +func vaultCode(args []string) error { return fmt.Errorf("not implemented") } +func vaultLock(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go new file mode 100644 index 00000000..42a4f21e --- /dev/null +++ b/cli/cmd_vault_test.go @@ -0,0 +1,30 @@ +package main + +import "testing" + +func TestVaultCommandsRegistered(t *testing.T) { + want := map[string]Tier{ + "vault setup": TierWrite, + "vault status": TierRead, + "vault list": TierRead, + "vault get": TierRead, + "vault search": TierRead, + "vault code": TierRead, + "vault lock": TierWrite, + } + got := map[string]Tier{} + for _, c := range vaultCommands() { + got[c.name()] = c.Tier + } + for name, tier := range want { + if got[name] != tier { + t.Errorf("command %q: tier=%q, want %q (registered=%v)", name, got[name], tier, got[name] != "") + } + } +} + +func TestVaultGroupInRegistry(t *testing.T) { + if !isCommandGroup(buildRegistry(), "vault") { + t.Fatal("`vault` group not wired into buildRegistry()") + } +} diff --git a/cli/homelab.go b/cli/homelab.go index 5f781791..62c0c8aa 100644 --- a/cli/homelab.go +++ b/cli/homelab.go @@ -23,6 +23,7 @@ func buildRegistry() []Command { reg = append(reg, usageCommands()...) reg = append(reg, haCommands()...) reg = append(reg, browserCommands()...) + reg = append(reg, vaultCommands()...) return reg } From cd44ca59217743774ac4fbf893d2520e9b915d43 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:15:32 +0000 Subject: [PATCH 02/13] feat(cli): vault creds loading from per-user Vault path --- cli/cmd_vault.go | 64 ++++++++++++++++++++++++++++++++++++++++++- cli/cmd_vault_test.go | 62 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 124 insertions(+), 2 deletions(-) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index fab86f52..70aa5cd3 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -1,6 +1,11 @@ package main -import "fmt" +import ( + "fmt" + "os" + "os/exec" + "strings" +) // vault verbs give each unix user no-HITL access to THEIR OWN Vaultwarden vault. // Identity is the kernel UID; per-user creds live in that user's isolated Vault @@ -26,6 +31,63 @@ func vaultCommands() []Command { } } +const vwUserPathPrefix = "secret/workstation/claude-users/" + +// vwCreds is one user's Vaultwarden auth material, read from their Vault path. +type vwCreds struct { + Email string + MasterPassword string + ClientID string + ClientSecret string +} + +// cmdRunner shells out to an external command with an explicit environment and +// returns trimmed stdout. Secrets are passed via envv, NEVER argv. Tests inject +// a fake; realRunner is the production implementation. +type cmdRunner func(name string, argv, envv []string) (string, error) + +func realRunner(name string, argv, envv []string) (string, error) { + cmd := exec.Command(name, argv...) + if envv != nil { + cmd.Env = envv + } + out, err := cmd.Output() + return strings.TrimSpace(string(out)), err +} + +func vwCredsPath(user string) string { return vwUserPathPrefix + user } + +func bwAppDataDir(uid string) string { return "/run/user/" + uid + "/homelab-bw" } + +// readVaultField returns one field from a KV-v2 path, "" if absent/error. +func readVaultField(run cmdRunner, field, path string) string { + out, err := run("vault", []string{"kv", "get", "-field=" + field, path}, nil) + if err != nil { + return "" + } + return out +} + +// loadCreds reads the four vaultwarden_* keys from the user's isolated path. +// A missing master password means the user hasn't onboarded. +func loadCreds(run cmdRunner, user string) (vwCreds, error) { + p := vwCredsPath(user) + c := vwCreds{ + Email: readVaultField(run, "vaultwarden_email", p), + MasterPassword: readVaultField(run, "vaultwarden_master_password", p), + ClientID: readVaultField(run, "vaultwarden_client_id", p), + ClientSecret: readVaultField(run, "vaultwarden_client_secret", p), + } + if c.MasterPassword == "" { + return vwCreds{}, fmt.Errorf("vault not configured for this user — run `homelab vault setup`") + } + return c, nil +} + +// vaultCurrentUser/vaultCurrentUID are seams for tests (avoid conflict with repo.go's currentUser func). +var vaultCurrentUser = func() string { return os.Getenv("USER") } +var vaultCurrentUID = func() string { return fmt.Sprintf("%d", os.Getuid()) } + func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } func vaultList(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 42a4f21e..76448484 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -1,6 +1,10 @@ package main -import "testing" +import ( + "reflect" + "strings" + "testing" +) func TestVaultCommandsRegistered(t *testing.T) { want := map[string]Tier{ @@ -28,3 +32,59 @@ func TestVaultGroupInRegistry(t *testing.T) { t.Fatal("`vault` group not wired into buildRegistry()") } } + +func TestVaultCredsPath(t *testing.T) { + if got := vwCredsPath("emo"); got != "secret/workstation/claude-users/emo" { + t.Fatalf("vwCredsPath = %q", got) + } +} + +func TestBwAppDataDir(t *testing.T) { + if got := bwAppDataDir("1001"); got != "/run/user/1001/homelab-bw" { + t.Fatalf("bwAppDataDir = %q", got) + } +} + +// fakeRunner records calls and returns canned stdout/err keyed by argv[0]+first arg. +type fakeRunner struct { + calls [][]string + out map[string]string // key: name+" "+strings.Join(argv," ") prefix-matched + err map[string]error + lastEnv []string +} + +func (f *fakeRunner) run(name string, argv, envv []string) (string, error) { + f.calls = append(f.calls, append([]string{name}, argv...)) + f.lastEnv = envv + key := name + " " + strings.Join(argv, " ") + for k, v := range f.out { + if strings.HasPrefix(key, k) { + return v, f.err[k] + } + } + return "", f.err[key] +} + +func TestLoadCredsReadsFourFields(t *testing.T) { + f := &fakeRunner{out: map[string]string{ + "vault kv get -field=vaultwarden_email secret/workstation/claude-users/emo": "emo@x.me", + "vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "hunter2", + "vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.abc", + "vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "sek", + }} + c, err := loadCreds(f.run, "emo") + if err != nil { + t.Fatalf("loadCreds: %v", err) + } + want := vwCreds{Email: "emo@x.me", MasterPassword: "hunter2", ClientID: "user.abc", ClientSecret: "sek"} + if !reflect.DeepEqual(c, want) { + t.Fatalf("loadCreds = %+v want %+v", c, want) + } +} + +func TestLoadCredsUnconfigured(t *testing.T) { + f := &fakeRunner{out: map[string]string{}} // every field empty + if _, err := loadCreds(f.run, "emo"); err == nil || !strings.Contains(err.Error(), "not configured") { + t.Fatalf("want 'not configured' error, got %v", err) + } +} From 06f4b87af10d4b5c9846e408944faaa46dbd6eb8 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:16:19 +0000 Subject: [PATCH 03/13] feat(cli): vault bw engine env/arg builders + unlock --- cli/cmd_vault.go | 56 +++++++++++++++++++++++++++++++++++++++++++ cli/cmd_vault_test.go | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index 70aa5cd3..4ee2ad47 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -88,6 +88,62 @@ func loadCreds(run cmdRunner, user string) (vwCreds, error) { var vaultCurrentUser = func() string { return os.Getenv("USER") } var vaultCurrentUID = func() string { return fmt.Sprintf("%d", os.Getuid()) } +// bwBaseEnv is the minimal non-secret environment bw/node need. We deliberately +// do NOT inherit the full parent env (keeps stray secrets out of the child). +func bwBaseEnv(appdata string) []string { + path := os.Getenv("PATH") + if path == "" { + path = "/usr/local/bin:/usr/bin:/bin" + } + return []string{ + "PATH=" + path, + "HOME=" + os.Getenv("HOME"), + "BITWARDENCLI_APPDATA_DIR=" + appdata, + "BW_NOINTERACTION=true", + } +} + +// bwSecretEnv adds the secret-bearing vars. session may be "" (pre-unlock). +func bwSecretEnv(appdata string, c vwCreds, session string) []string { + env := bwBaseEnv(appdata) + env = append(env, + "BW_CLIENTID="+c.ClientID, + "BW_CLIENTSECRET="+c.ClientSecret, + "BW_PASSWORD="+c.MasterPassword, + ) + if session != "" { + env = append(env, "BW_SESSION="+session) + } + return env +} + +func bwLoginArgs() []string { return []string{"login", "--apikey"} } +func bwUnlockArgs() []string { return []string{"unlock", "--passwordenv", "BW_PASSWORD", "--raw"} } +func bwGetArgs(field, name string) []string { return []string{"get", field, name} } +func bwStatusArgs() []string { return []string{"status"} } + +func bwListArgs(search string) []string { + a := []string{"list", "items"} + if search != "" { + a = append(a, "--search", search) + } + return a +} + +// bwUnlock runs `bw unlock` and returns the raw session key. +func bwUnlock(run cmdRunner, env []string) (string, error) { + out, err := run("bw", bwUnlockArgs(), env) + if err != nil { + return "", fmt.Errorf("bw unlock failed (wrong master password? run `homelab vault setup`): %w", err) + } + return out, nil +} + +// bwGet fetches one field of one item; session must be present in env. +func bwGet(run cmdRunner, env []string, field, name string) (string, error) { + return run("bw", bwGetArgs(field, name), env) +} + func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } func vaultList(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 76448484..52a91a47 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -88,3 +88,55 @@ func TestLoadCredsUnconfigured(t *testing.T) { t.Fatalf("want 'not configured' error, got %v", err) } } + +func TestBwEnvCarriesSecretsNotArgv(t *testing.T) { + c := vwCreds{ClientID: "user.abc", ClientSecret: "sek", MasterPassword: "hunter2"} + env := bwSecretEnv("/run/user/1001/homelab-bw", c, "SESSIONKEY") + joined := strings.Join(env, "\n") + for _, want := range []string{ + "BW_CLIENTID=user.abc", "BW_CLIENTSECRET=sek", "BW_PASSWORD=hunter2", + "BW_SESSION=SESSIONKEY", "BITWARDENCLI_APPDATA_DIR=/run/user/1001/homelab-bw", + } { + if !strings.Contains(joined, want) { + t.Errorf("bwSecretEnv missing %q", want) + } + } + if strings.Contains(joined, "PATH=") == false { + t.Error("bwSecretEnv must keep a PATH so node/bw resolve") + } +} + +func TestBwGetArgsHasNoSessionInArgv(t *testing.T) { + argv := bwGetArgs("password", "github") + for _, a := range argv { + if strings.Contains(a, "SESSION") || a == "--session" { + t.Fatalf("session must travel via env, not argv: %v", argv) + } + } + if !reflect.DeepEqual(argv, []string{"get", "password", "github"}) { + t.Fatalf("bwGetArgs = %v", argv) + } +} + +func TestBwListArgs(t *testing.T) { + if got := bwListArgs(""); !reflect.DeepEqual(got, []string{"list", "items"}) { + t.Fatalf("bwListArgs('') = %v", got) + } + if got := bwListArgs("git"); !reflect.DeepEqual(got, []string{"list", "items", "--search", "git"}) { + t.Fatalf("bwListArgs('git') = %v", got) + } +} + +func TestBwUnlockReturnsSession(t *testing.T) { + f := &fakeRunner{out: map[string]string{"bw unlock": "THE-SESSION-KEY"}} + env := bwSecretEnv("/run/user/1001/homelab-bw", vwCreds{MasterPassword: "pw"}, "") + sess, err := bwUnlock(f.run, env) + if err != nil || sess != "THE-SESSION-KEY" { + t.Fatalf("bwUnlock = %q, %v", sess, err) + } + // argv must use --passwordenv + --raw, never the password literal + last := f.calls[len(f.calls)-1] + if strings.Join(last, " ") != "bw unlock --passwordenv BW_PASSWORD --raw" { + t.Fatalf("unlock argv = %v", last) + } +} From 81122f86074a03fec708dcb3b6c103dab7c96fdb Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:17:13 +0000 Subject: [PATCH 04/13] feat(cli): TTY-aware return + OSC52 clipboard with terminal gating --- cli/cmd_vault.go | 38 ++++++++++++++++++++++++++++++++++++++ cli/cmd_vault_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index 4ee2ad47..84ba5dd9 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "fmt" "os" "os/exec" @@ -144,6 +145,43 @@ func bwGet(run cmdRunner, env []string, field, name string) (string, error) { return run("bw", bwGetArgs(field, name), env) } +func returnMode(isTTY bool) string { + if isTTY { + return "clipboard" + } + return "stdout" +} + +// stdoutIsTTY reports whether stdout is a character device (a terminal). +func stdoutIsTTY() bool { + fi, err := os.Stdout.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + +// osc52 returns the OSC 52 escape that makes the local terminal copy payload to +// the system clipboard (works over SSH; no X11). osc52clear copies empty. +func osc52(payload string) string { + return "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte(payload)) + "\a" +} +func osc52clear() string { return "\x1b]52;c;\a" } + +// terminalAllowed gates OSC 52: only terminals known to honor clipboard writes, +// else we'd dump the secret's base64 into scrollback on unsupported terminals. +func terminalAllowed(term, termProgram string) bool { + t := strings.ToLower(term) + p := strings.ToLower(termProgram) + for _, ok := range []string{"kitty", "alacritty", "foot", "wezterm", "ghostty", "tmux", "screen"} { + if strings.Contains(t, ok) || strings.Contains(p, ok) { + return true + } + } + // xterm proper supports it only when the program is a known-good emulator. + return false +} + func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } func vaultList(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 52a91a47..30fd540d 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "reflect" "strings" "testing" @@ -140,3 +141,38 @@ func TestBwUnlockReturnsSession(t *testing.T) { t.Fatalf("unlock argv = %v", last) } } + +func TestReturnMode(t *testing.T) { + if returnMode(true) != "clipboard" || returnMode(false) != "stdout" { + t.Fatal("returnMode wrong") + } +} + +func TestOSC52Encode(t *testing.T) { + got := osc52("secret") + want := "\x1b]52;c;" + base64.StdEncoding.EncodeToString([]byte("secret")) + "\a" + if got != want { + t.Fatalf("osc52 = %q want %q", got, want) + } + if osc52clear() != "\x1b]52;c;\a" { + t.Fatalf("osc52clear wrong: %q", osc52clear()) + } +} + +func TestTerminalAllowed(t *testing.T) { + allow := []struct{ term, prog string }{ + {"xterm-kitty", ""}, {"alacritty", ""}, {"foot", ""}, {"tmux-256color", ""}, + {"screen-256color", ""}, {"xterm-256color", "WezTerm"}, {"xterm-256color", "ghostty"}, + } + for _, c := range allow { + if !terminalAllowed(c.term, c.prog) { + t.Errorf("terminalAllowed(%q,%q) = false, want true", c.term, c.prog) + } + } + deny := []struct{ term, prog string }{{"dumb", ""}, {"", ""}, {"vt100", ""}} + for _, c := range deny { + if terminalAllowed(c.term, c.prog) { + t.Errorf("terminalAllowed(%q,%q) = true, want false", c.term, c.prog) + } + } +} From 5bae2a3907e3658056442ebb1514929b198518c9 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:17:50 +0000 Subject: [PATCH 05/13] feat(cli): privacy-aware vault op-log (process, never the secret) --- cli/cmd_vault.go | 33 +++++++++++++++++++++++++++++++++ cli/cmd_vault_test.go | 14 ++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index 84ba5dd9..55ed4030 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -182,6 +182,39 @@ func terminalAllowed(term, termProgram string) bool { return false } +// opRecord is one CLI operation. ItemName is accepted for the caller's +// convenience but is INTENTIONALLY never rendered into the log line — auditing +// which of your own logins you opened is itself sensitive, and per-item reads +// are invisible server-side anyway (spec §9a). +type opRecord struct { + User string + Verb string + PID int + PPID int + ParentComm string + ItemName string // never logged +} + +func opLogLine(r opRecord) string { + return fmt.Sprintf("user=%s verb=%s pid=%d ppid=%d parent=%s", + r.User, r.Verb, r.PID, r.PPID, r.ParentComm) +} + +// parentComm reads /proc//comm (best-effort; "" on failure). +func parentComm(ppid int) string { + b, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", ppid)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// writeOpLog appends one privacy-aware line to the user's op-log (best-effort; +// never blocks or fails the command). Goes to syslog so it ships to Loki. +func writeOpLog(r opRecord) { + exec.Command("logger", "-t", "homelab-vault", opLogLine(r)).Run() // best-effort +} + func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } func vaultList(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 30fd540d..b3a20ef4 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -176,3 +176,17 @@ func TestTerminalAllowed(t *testing.T) { } } } + +func TestOpLogLineHasNoSecretOrItem(t *testing.T) { + line := opLogLine(opRecord{User: "emo", Verb: "get", PID: 10, PPID: 9, ParentComm: "claude", ItemName: "Chase Bank"}) + for _, must := range []string{"user=emo", "verb=get", "ppid=9", "parent=claude"} { + if !strings.Contains(line, must) { + t.Errorf("op-log missing %q: %s", must, line) + } + } + for _, mustNot := range []string{"Chase", "password", "secret"} { + if strings.Contains(line, mustNot) { + t.Fatalf("op-log LEAKS %q (privacy violation): %s", mustNot, line) + } + } +} From 2dd12fc6be4880df897574571a72f6ac30fe4298 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:18:36 +0000 Subject: [PATCH 06/13] feat(cli): vault session bootstrap with per-user flock + no-coredump --- cli/cmd_vault.go | 54 +++++++++++++++++++++++++++++++++++++++++++ cli/cmd_vault_test.go | 6 +++++ 2 files changed, 60 insertions(+) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index 55ed4030..825f14da 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -6,6 +6,7 @@ import ( "os" "os/exec" "strings" + "syscall" ) // vault verbs give each unix user no-HITL access to THEIR OWN Vaultwarden vault. @@ -215,6 +216,59 @@ func writeOpLog(r opRecord) { exec.Command("logger", "-t", "homelab-vault", opLogLine(r)).Run() // best-effort } +func vaultLockPath(uid string) string { return "/run/user/" + uid + "/homelab-vault.lock" } + +// hardenProcess disables core dumps so a bw/homelab crash can't spill the master +// password to a core file. Best-effort. +func hardenProcess() { + _ = syscall.Setrlimit(syscall.RLIMIT_CORE, &syscall.Rlimit{Cur: 0, Max: 0}) +} + +// withUserLock serializes bw mutations for this user (concurrent Claude sessions +// as the same user otherwise race bw's appdata). Returns an unlock func. +func withUserLock(uid string) (func(), error) { + f, err := os.OpenFile(vaultLockPath(uid), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + f.Close() + return nil, err + } + return func() { syscall.Flock(int(f.Fd()), syscall.LOCK_UN); f.Close() }, nil +} + +// session is one usable bw context: the env (with BW_SESSION) ready for `bw get`. +type session struct { + env []string +} + +// openSession resolves creds, ensures login, unlocks, and returns a ready env. +// Caller must hold the user lock. appdata is created on tmpfs (0700). +func openSession(run cmdRunner, user, uid string) (session, error) { + creds, err := loadCreds(run, user) + if err != nil { + return session{}, err + } + appdata := bwAppDataDir(uid) + if err := os.MkdirAll(appdata, 0700); err != nil { + return session{}, fmt.Errorf("create bw appdata %s: %w", appdata, err) + } + loginEnv := bwSecretEnv(appdata, creds, "") + // Ensure server is set and we're logged in (idempotent; ignore "already"). + _, _ = run("bw", []string{"config", "server", "https://vaultwarden.viktorbarzin.me"}, loginEnv) + if st, _ := run("bw", bwStatusArgs(), loginEnv); !strings.Contains(st, "\"status\"") || strings.Contains(st, "unauthenticated") { + if _, err := run("bw", bwLoginArgs(), loginEnv); err != nil { + return session{}, fmt.Errorf("bw login --apikey failed (API key valid? run `homelab vault setup`): %w", err) + } + } + sess, err := bwUnlock(run, loginEnv) + if err != nil { + return session{}, err + } + return session{env: bwSecretEnv(appdata, creds, sess)}, nil +} + func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } func vaultList(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index b3a20ef4..da22692b 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -190,3 +190,9 @@ func TestOpLogLineHasNoSecretOrItem(t *testing.T) { } } } + +func TestLockPath(t *testing.T) { + if got := vaultLockPath("1001"); got != "/run/user/1001/homelab-vault.lock" { + t.Fatalf("vaultLockPath = %q", got) + } +} From 365340b37d0d80a47ea006342fc0c5c71bae162b Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:20:05 +0000 Subject: [PATCH 07/13] feat(cli): homelab vault get with TTY-aware return --- cli/cmd_vault.go | 106 ++++++++++++++++++++++++++++++++++++++++-- cli/cmd_vault_test.go | 37 +++++++++++++++ 2 files changed, 138 insertions(+), 5 deletions(-) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index 825f14da..b575f4b9 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -269,10 +269,106 @@ func openSession(run cmdRunner, user, uid string) (session, error) { return session{env: bwSecretEnv(appdata, creds, sess)}, nil } -func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } +type getOpts struct { + name string + field string + json bool +} + +var validGetFields = map[string]bool{"password": true, "username": true, "uri": true, "notes": true, "totp": true} + +func parseGetArgs(args []string) (getOpts, error) { + o := getOpts{field: "password"} + for i := 0; i < len(args); i++ { + a := args[i] + switch { + case a == "--json": + o.json = true + case a == "--field" && i+1 < len(args): + o.field = args[i+1] + i++ + case strings.HasPrefix(a, "--field="): + o.field = strings.TrimPrefix(a, "--field=") + case !strings.HasPrefix(a, "-") && o.name == "": + o.name = a + } + } + if o.name == "" { + return o, fmt.Errorf("usage: homelab vault get [--field password|username|uri|notes|totp] [--json]") + } + if !validGetFields[o.field] { + return o, fmt.Errorf("invalid --field %q (want password|username|uri|notes|totp)", o.field) + } + return o, nil +} + +// getValue opens a session and fetches one field. Pure of I/O side effects +// besides the runner, so it is unit-tested with a fake runner. +func getValue(run cmdRunner, user, uid string, o getOpts) (string, error) { + s, err := openSession(run, user, uid) + if err != nil { + return "", err + } + return bwGet(run, s.env, o.field, o.name) +} + +// emitSecret returns it TTY-aware: clipboard (OSC52, gated, auto-clear) on a +// terminal; stdout otherwise. Returns the human-facing status string (never the +// secret) for the clipboard path. +func emitSecret(value string) { + if returnMode(stdoutIsTTY()) == "stdout" { + fmt.Println(value) + return + } + if !terminalAllowed(os.Getenv("TERM"), os.Getenv("TERM_PROGRAM")) { + fmt.Fprintln(os.Stderr, "refusing to print secret: this terminal can't do OSC52 clipboard safely; pipe the command or use a supported terminal") + return + } + fmt.Fprint(os.Stderr, osc52(value)) + fmt.Fprintln(os.Stderr, "copied to clipboard; clearing in 30s") + clearClipboardAfter(30) +} + +// clearClipboardAfter spawns a detached background clear so the secret doesn't +// linger in the clipboard. Best-effort. +func clearClipboardAfter(seconds int) { + exec.Command("sh", "-c", fmt.Sprintf("sleep %d; printf '%s'", seconds, osc52clear())).Start() +} + +func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } + func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } -func vaultList(args []string) error { return fmt.Errorf("not implemented") } -func vaultGet(args []string) error { return fmt.Errorf("not implemented") } + +func vaultList(args []string) error { return fmt.Errorf("not implemented") } + +func vaultGet(args []string) error { + hardenProcess() + o, err := parseGetArgs(args) + if err != nil { + return err + } + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) + if err != nil { + return err + } + defer unlock() + user := vaultCurrentUser() + val, err := getValue(realRunner, user, uid, o) + if err != nil { + return err + } + writeOpLog(opRecord{User: user, Verb: "get", PID: os.Getpid(), PPID: os.Getppid(), ParentComm: parentComm(os.Getppid()), ItemName: o.name}) + if o.json { + fmt.Printf("{%q:%q}\n", o.field, val) + return nil + } + emitSecret(val) + return nil +} + func vaultSearch(args []string) error { return fmt.Errorf("not implemented") } -func vaultCode(args []string) error { return fmt.Errorf("not implemented") } -func vaultLock(args []string) error { return fmt.Errorf("not implemented") } + +func vaultCode(args []string) error { return fmt.Errorf("not implemented") } + +func vaultLock(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index da22692b..09721ef7 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -2,6 +2,8 @@ package main import ( "encoding/base64" + "fmt" + "os" "reflect" "strings" "testing" @@ -196,3 +198,38 @@ func TestLockPath(t *testing.T) { t.Fatalf("vaultLockPath = %q", got) } } + +func TestParseGetArgs(t *testing.T) { + o, err := parseGetArgs([]string{"github", "--field", "username", "--json"}) + if err != nil || o.name != "github" || o.field != "username" || !o.json { + t.Fatalf("parseGetArgs = %+v err=%v", o, err) + } + d, _ := parseGetArgs([]string{"github"}) + if d.field != "password" || d.json { + t.Fatalf("defaults wrong: %+v", d) + } + if _, err := parseGetArgs([]string{}); err == nil { + t.Fatal("get with no name must error") + } + if _, err := parseGetArgs([]string{"x", "--field", "evil"}); err == nil { + t.Fatal("invalid --field must error") + } +} + +// getValue is the testable core: given a runner + opts, returns the secret value. +func TestGetValueFlow(t *testing.T) { + f := &fakeRunner{out: map[string]string{ + "vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "pw", + "vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x", + "vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "cs", + "bw status": `{"status":"locked"}`, + "bw unlock": "SESS", + "bw get password github": "p@ss", + }} + // Use real UID so os.MkdirAll(/run/user//homelab-bw) succeeds. + uid := fmt.Sprintf("%d", os.Getuid()) + val, err := getValue(f.run, "emo", uid, getOpts{name: "github", field: "password"}) + if err != nil || val != "p@ss" { + t.Fatalf("getValue = %q, %v", val, err) + } +} From e20033855d9eae5ac6c99dbb39044631102a9866 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:21:07 +0000 Subject: [PATCH 08/13] feat(cli): vault list/search/code/status/lock --- cli/cmd_vault.go | 133 +++++++++++++++++++++++++++++++++++++++--- cli/cmd_vault_test.go | 17 ++++++ 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index b575f4b9..d84006ef 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -2,6 +2,7 @@ package main import ( "encoding/base64" + "encoding/json" "fmt" "os" "os/exec" @@ -335,12 +336,131 @@ func clearClipboardAfter(seconds int) { exec.Command("sh", "-c", fmt.Sprintf("sleep %d; printf '%s'", seconds, osc52clear())).Start() } +// listNames extracts "name (id)" from `bw list items` JSON; never values. +func listNames(jsonOut string) []string { + var items []struct { + ID string `json:"id"` + Name string `json:"name"` + } + if err := json.Unmarshal([]byte(jsonOut), &items); err != nil { + return nil + } + out := make([]string, 0, len(items)) + for _, it := range items { + out = append(out, fmt.Sprintf("%s (%s)", it.Name, it.ID)) + } + return out +} + +func runList(run cmdRunner, user, uid, search string) ([]string, error) { + s, err := openSession(run, user, uid) + if err != nil { + return nil, err + } + out, err := run("bw", bwListArgs(search), s.env) + if err != nil { + return nil, err + } + return listNames(out), nil +} + +func vaultList(args []string) error { + hardenProcess() + search := "" + for i := 0; i < len(args); i++ { + if args[i] == "--search" && i+1 < len(args) { + search = args[i+1] + i++ + } else if strings.HasPrefix(args[i], "--search=") { + search = strings.TrimPrefix(args[i], "--search=") + } + } + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) + if err != nil { + return err + } + defer unlock() + names, err := runList(realRunner, vaultCurrentUser(), uid, search) + if err != nil { + return err + } + for _, n := range names { + fmt.Println(n) + } + return nil +} + +func vaultSearch(args []string) error { + if len(args) == 0 { + return fmt.Errorf("usage: homelab vault search ") + } + return vaultList([]string{"--search", strings.Join(args, " ")}) +} + +func vaultCode(args []string) error { + hardenProcess() + if len(args) == 0 { + return fmt.Errorf("usage: homelab vault code ") + } + name := args[0] + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) + if err != nil { + return err + } + defer unlock() + user := vaultCurrentUser() + val, err := getValue(realRunner, user, uid, getOpts{name: name, field: "totp"}) + if err != nil { + return err + } + // TOTP is the most sensitive op: log AND emit an ntfy-bound marker (spec §9a-d). + writeOpLog(opRecord{User: user, Verb: "code", PID: os.Getpid(), PPID: os.Getppid(), ParentComm: parentComm(os.Getppid()), ItemName: name}) + exec.Command("logger", "-t", "homelab-vault-totp", "user="+user+" totp-fetch parent="+parentComm(os.Getppid())).Run() + emitSecret(val) + return nil +} + +// statusSummary reports config/reachability without revealing secrets. +func statusSummary(run cmdRunner, user, uid string) string { + if _, err := loadCreds(run, user); err != nil { + return "vault: not configured — run `homelab vault setup`" + } + s, err := openSession(run, user, uid) + if err != nil { + return "vault: configured, but unlock/login FAILED (creds stale? run `homelab vault setup`): " + err.Error() + } + if _, err := run("bw", []string{"sync"}, s.env); err != nil { + return "vault: configured + unlocked, but sync/reachability failed: " + err.Error() + } + return "vault: configured, unlocked, reachable ✓" +} + +func vaultStatus(args []string) error { + hardenProcess() + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) + if err != nil { + return err + } + defer unlock() + fmt.Println(statusSummary(realRunner, vaultCurrentUser(), uid)) + return nil +} + +func vaultLock(args []string) error { + appdata := bwAppDataDir(vaultCurrentUID()) + _, _ = realRunner("bw", []string{"lock"}, bwBaseEnv(appdata)) + _, err := realRunner("bw", []string{"logout"}, bwBaseEnv(appdata)) + if err == nil { + fmt.Println("locked") + } + return nil // lock/logout best-effort; never error the caller +} + func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } -func vaultStatus(args []string) error { return fmt.Errorf("not implemented") } - -func vaultList(args []string) error { return fmt.Errorf("not implemented") } - func vaultGet(args []string) error { hardenProcess() o, err := parseGetArgs(args) @@ -367,8 +487,3 @@ func vaultGet(args []string) error { return nil } -func vaultSearch(args []string) error { return fmt.Errorf("not implemented") } - -func vaultCode(args []string) error { return fmt.Errorf("not implemented") } - -func vaultLock(args []string) error { return fmt.Errorf("not implemented") } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 09721ef7..7298c0e7 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -216,6 +216,23 @@ func TestParseGetArgs(t *testing.T) { } } +func TestListNamesParsing(t *testing.T) { + // bw list items returns JSON; listNames extracts name + id only. + js := `[{"id":"1","name":"GitHub","login":{"username":"u"}},{"id":"2","name":"AWS"}]` + names := listNames(js) + if len(names) != 2 || names[0] != "GitHub (1)" || names[1] != "AWS (2)" { + t.Fatalf("listNames = %v", names) + } +} + +func TestStatusSummaryUnconfigured(t *testing.T) { + f := &fakeRunner{out: map[string]string{}} // no creds + s := statusSummary(f.run, "emo", "1001") + if !strings.Contains(s, "not configured") { + t.Fatalf("status = %q", s) + } +} + // getValue is the testable core: given a runner + opts, returns the secret value. func TestGetValueFlow(t *testing.T) { f := &fakeRunner{out: map[string]string{ From 5a864cf19ca1bc8ad84e43f752c5955bf67f2508 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:21:57 +0000 Subject: [PATCH 09/13] feat(cli): homelab vault setup onboarding (one-time, self-service) --- cli/cmd_vault.go | 67 ++++++++++++++++++++++++++++++++++++++++++- cli/cmd_vault_test.go | 10 +++++++ 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index d84006ef..d4db36e1 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -1,6 +1,7 @@ package main import ( + "bufio" "encoding/base64" "encoding/json" "fmt" @@ -459,7 +460,71 @@ func vaultLock(args []string) error { return nil // lock/logout best-effort; never error the caller } -func vaultSetup(args []string) error { return fmt.Errorf("not implemented") } +func vaultPutArgs(user string, c vwCreds) []string { + return []string{"kv", "patch", vwCredsPath(user), + "vaultwarden_email=" + c.Email, + "vaultwarden_master_password=" + c.MasterPassword, + "vaultwarden_client_id=" + c.ClientID, + "vaultwarden_client_secret=" + c.ClientSecret, + } +} + +// promptNoEcho reads one line without terminal echo (for the master password). +func promptNoEcho(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + exec.Command("stty", "-echo").Run() + defer func() { exec.Command("stty", "echo").Run(); fmt.Fprintln(os.Stderr) }() + r := bufio.NewReader(os.Stdin) + line, err := r.ReadString('\n') + return strings.TrimSpace(line), err +} + +func promptLine(prompt string) (string, error) { + fmt.Fprint(os.Stderr, prompt) + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + return strings.TrimSpace(line), err +} + +func vaultSetup(args []string) error { + hardenProcess() + fmt.Fprintln(os.Stderr, "One-time setup. Stored ONLY in your own Vault path; the admin never sees it.") + fmt.Fprintln(os.Stderr, "Get your API key at https://vaultwarden.viktorbarzin.me → Settings → Security → Keys → View API key.") + email, err := promptLine("Vaultwarden email: ") + if err != nil { + return err + } + clientID, err := promptLine("API key client_id (user.xxxx): ") + if err != nil { + return err + } + clientSecret, err := promptNoEcho("API key client_secret: ") + if err != nil { + return err + } + master, err := promptNoEcho("Master password: ") + if err != nil { + return err + } + if master == "" || clientID == "" || clientSecret == "" { + return fmt.Errorf("all fields are required") + } + c := vwCreds{Email: email, MasterPassword: master, ClientID: clientID, ClientSecret: clientSecret} + if _, err := realRunner("vault", vaultPutArgs(vaultCurrentUser(), c), nil); err != nil { + return fmt.Errorf("writing creds to your Vault path failed (scoped token present?): %w", err) + } + fmt.Fprintln(os.Stderr, "Stored. Verifying unlock…") + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) + if err != nil { + return err + } + defer unlock() + if _, err := openSession(realRunner, vaultCurrentUser(), uid); err != nil { + return fmt.Errorf("stored, but verification failed — double-check master password / API key: %w", err) + } + fmt.Fprintln(os.Stderr, "✓ Verified. Fetches are now AFK.") + return nil +} func vaultGet(args []string) error { hardenProcess() diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 7298c0e7..d7f845e0 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -233,6 +233,16 @@ func TestStatusSummaryUnconfigured(t *testing.T) { } } +func TestVaultPutArgs(t *testing.T) { + got := vaultPutArgs("emo", vwCreds{Email: "e", MasterPassword: "m", ClientID: "ci", ClientSecret: "cs"}) + want := []string{"kv", "patch", "secret/workstation/claude-users/emo", + "vaultwarden_email=e", "vaultwarden_master_password=m", + "vaultwarden_client_id=ci", "vaultwarden_client_secret=cs"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("vaultPutArgs = %v", got) + } +} + // getValue is the testable core: given a runner + opts, returns the secret value. func TestGetValueFlow(t *testing.T) { f := &fakeRunner{out: map[string]string{ From 772aed5370e3e01fb6003cccb901c13fa0b68c83 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:28:31 +0000 Subject: [PATCH 10/13] fix(cli): vault security review fixes C1 (critical): setup wrote the master password + API client_secret as `vault kv patch key=value` argv, leaking them via /proc//cmdline to same-UID processes. Now written via stdin (key=- form); only email + client_id (non-credentials) remain in argv. I1: `get --json` refused on a TTY (was dumping the secret to scrollback). M1: vaultLock now holds the per-user flock (it mutates bw state). M4: bw login-detection parses status JSON instead of substring matching. M5: clipboard path refuses when stderr is not a TTY (was silently failing). M6: realRunner trims only trailing newline, preserving secret whitespace; secret prompts likewise. Adds security-property tests: no secret in argv across the get flow, clipboard decision matrix, --json TTY gate, bw status parsing. --- cli/cmd_vault.go | 138 ++++++++++++++++++++++++++++++++++-------- cli/cmd_vault_test.go | 98 ++++++++++++++++++++++++++++-- 2 files changed, 206 insertions(+), 30 deletions(-) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index d4db36e1..21d0313b 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -25,7 +25,7 @@ func vaultCommands() []Command { {Path: []string{"vault", "list"}, Tier: TierRead, Summary: "list your item names: vault list [--search Q]", Run: vaultList}, {Path: []string{"vault", "get"}, Tier: TierRead, - Summary: "fetch one item: vault get [--field password|username|uri|notes] [--json]", Run: vaultGet}, + Summary: "fetch one item: vault get [--field password|username|uri|notes|totp] [--json]", Run: vaultGet}, {Path: []string{"vault", "search"}, Tier: TierRead, Summary: "search your item names: vault search ", Run: vaultSearch}, {Path: []string{"vault", "code"}, Tier: TierRead, @@ -56,7 +56,22 @@ func realRunner(name string, argv, envv []string) (string, error) { cmd.Env = envv } out, err := cmd.Output() - return strings.TrimSpace(string(out)), err + // Trim only the trailing newline the tool appends — NOT all whitespace, so a + // fetched secret with significant leading/trailing spaces is preserved. + return strings.TrimRight(string(out), "\r\n"), err +} + +// realRunnerStdin runs a command feeding `stdin` to it, for secret values that +// must NOT appear in argv (visible via ps / /proc//cmdline to same-UID +// processes). Used by setup to write the master password / client_secret. +func realRunnerStdin(name string, argv, envv []string, stdin string) (string, error) { + cmd := exec.Command(name, argv...) + if envv != nil { + cmd.Env = envv + } + cmd.Stdin = strings.NewReader(stdin) + out, err := cmd.Output() + return strings.TrimRight(string(out), "\r\n"), err } func vwCredsPath(user string) string { return vwUserPathPrefix + user } @@ -126,6 +141,18 @@ func bwUnlockArgs() []string { return []string{"unlock", "--passwordenv", "BW_PA func bwGetArgs(field, name string) []string { return []string{"get", field, name} } func bwStatusArgs() []string { return []string{"status"} } +// bwNeedsLogin parses `bw status` JSON and reports whether a `bw login` is +// required. Unparseable/empty output → true (safer to attempt login). +func bwNeedsLogin(statusJSON string) bool { + var s struct { + Status string `json:"status"` + } + if err := json.Unmarshal([]byte(statusJSON), &s); err != nil { + return true + } + return s.Status == "unauthenticated" || s.Status == "" +} + func bwListArgs(search string) []string { a := []string{"list", "items"} if search != "" { @@ -164,6 +191,16 @@ func stdoutIsTTY() bool { return fi.Mode()&os.ModeCharDevice != 0 } +// stderrIsTTY reports whether stderr is a terminal (the OSC52 escape is written +// to stderr, so the clipboard path is only viable when stderr is a terminal). +func stderrIsTTY() bool { + fi, err := os.Stderr.Stat() + if err != nil { + return false + } + return fi.Mode()&os.ModeCharDevice != 0 +} + // osc52 returns the OSC 52 escape that makes the local terminal copy payload to // the system clipboard (works over SSH; no X11). osc52clear copies empty. func osc52(payload string) string { @@ -259,7 +296,8 @@ func openSession(run cmdRunner, user, uid string) (session, error) { loginEnv := bwSecretEnv(appdata, creds, "") // Ensure server is set and we're logged in (idempotent; ignore "already"). _, _ = run("bw", []string{"config", "server", "https://vaultwarden.viktorbarzin.me"}, loginEnv) - if st, _ := run("bw", bwStatusArgs(), loginEnv); !strings.Contains(st, "\"status\"") || strings.Contains(st, "unauthenticated") { + st, _ := run("bw", bwStatusArgs(), loginEnv) + if bwNeedsLogin(st) { if _, err := run("bw", bwLoginArgs(), loginEnv); err != nil { return session{}, fmt.Errorf("bw login --apikey failed (API key valid? run `homelab vault setup`): %w", err) } @@ -314,21 +352,38 @@ func getValue(run cmdRunner, user, uid string, o getOpts) (string, error) { return bwGet(run, s.env, o.field, o.name) } -// emitSecret returns it TTY-aware: clipboard (OSC52, gated, auto-clear) on a -// terminal; stdout otherwise. Returns the human-facing status string (never the -// secret) for the clipboard path. +// clipboardDecision picks how to return a secret value. "stdout" prints it (a +// pipe/agent — the intended machine path); "clipboard" copies via OSC52; +// "refuse" emits nothing sensitive (would otherwise risk dumping the secret's +// base64 into scrollback, or silently fail because the OSC52 escape goes to a +// non-terminal stderr). +func clipboardDecision(stdoutTTY, stderrTTY bool, term, termProgram string) string { + if !stdoutTTY { + return "stdout" + } + if terminalAllowed(term, termProgram) && stderrTTY { + return "clipboard" + } + return "refuse" +} + +// jsonToStdoutOK reports whether `--json` may print the secret to stdout — only +// when stdout is NOT a terminal (i.e. piped to a machine consumer). +func jsonToStdoutOK(stdoutTTY bool) bool { return !stdoutTTY } + +// emitSecret returns a value TTY-aware (see clipboardDecision). Never prints the +// secret to a terminal's stdout/scrollback. func emitSecret(value string) { - if returnMode(stdoutIsTTY()) == "stdout" { + switch clipboardDecision(stdoutIsTTY(), stderrIsTTY(), os.Getenv("TERM"), os.Getenv("TERM_PROGRAM")) { + case "stdout": fmt.Println(value) - return + case "clipboard": + fmt.Fprint(os.Stderr, osc52(value)) + fmt.Fprintln(os.Stderr, "copied to clipboard; clearing in 30s") + clearClipboardAfter(30) + default: // refuse + fmt.Fprintln(os.Stderr, "refusing to print secret: this terminal can't do OSC52 clipboard safely; pipe the command (e.g. | cat) or use a supported terminal") } - if !terminalAllowed(os.Getenv("TERM"), os.Getenv("TERM_PROGRAM")) { - fmt.Fprintln(os.Stderr, "refusing to print secret: this terminal can't do OSC52 clipboard safely; pipe the command or use a supported terminal") - return - } - fmt.Fprint(os.Stderr, osc52(value)) - fmt.Fprintln(os.Stderr, "copied to clipboard; clearing in 30s") - clearClipboardAfter(30) } // clearClipboardAfter spawns a detached background clear so the secret doesn't @@ -451,24 +506,52 @@ func vaultStatus(args []string) error { } func vaultLock(args []string) error { - appdata := bwAppDataDir(vaultCurrentUID()) + uid := vaultCurrentUID() + unlock, err := withUserLock(uid) // logout mutates bw state — serialize with get/list + if err != nil { + return err + } + defer unlock() + appdata := bwAppDataDir(uid) _, _ = realRunner("bw", []string{"lock"}, bwBaseEnv(appdata)) - _, err := realRunner("bw", []string{"logout"}, bwBaseEnv(appdata)) - if err == nil { + _, logoutErr := realRunner("bw", []string{"logout"}, bwBaseEnv(appdata)) + if logoutErr == nil { fmt.Println("locked") } return nil // lock/logout best-effort; never error the caller } -func vaultPutArgs(user string, c vwCreds) []string { +// vaultPatchPublicArgs writes the non-secret identifiers via argv. Neither the +// email nor the API client_id is a usable credential on its own. +func vaultPatchPublicArgs(user, email, clientID string) []string { return []string{"kv", "patch", vwCredsPath(user), - "vaultwarden_email=" + c.Email, - "vaultwarden_master_password=" + c.MasterPassword, - "vaultwarden_client_id=" + c.ClientID, - "vaultwarden_client_secret=" + c.ClientSecret, + "vaultwarden_email=" + email, + "vaultwarden_client_id=" + clientID, } } +// vaultPatchSecretArgs writes ONE secret value via the `key=-` stdin form, so +// the value never appears in argv (ps / /proc//cmdline). The value is fed +// on stdin by realRunnerStdin. +func vaultPatchSecretArgs(user, key string) []string { + return []string{"kv", "patch", vwCredsPath(user), key + "=-"} +} + +// writeCreds stores all four fields in the user's Vault path. The two real +// secrets (master password, API client_secret) go via stdin — never argv. +func writeCreds(user string, c vwCreds) error { + if _, err := realRunner("vault", vaultPatchPublicArgs(user, c.Email, c.ClientID), nil); err != nil { + return err + } + if _, err := realRunnerStdin("vault", vaultPatchSecretArgs(user, "vaultwarden_master_password"), nil, c.MasterPassword); err != nil { + return err + } + if _, err := realRunnerStdin("vault", vaultPatchSecretArgs(user, "vaultwarden_client_secret"), nil, c.ClientSecret); err != nil { + return err + } + return nil +} + // promptNoEcho reads one line without terminal echo (for the master password). func promptNoEcho(prompt string) (string, error) { fmt.Fprint(os.Stderr, prompt) @@ -476,7 +559,9 @@ func promptNoEcho(prompt string) (string, error) { defer func() { exec.Command("stty", "echo").Run(); fmt.Fprintln(os.Stderr) }() r := bufio.NewReader(os.Stdin) line, err := r.ReadString('\n') - return strings.TrimSpace(line), err + // Trim only the line terminator — a master password / API secret may + // legitimately contain leading/trailing spaces. + return strings.TrimRight(line, "\r\n"), err } func promptLine(prompt string) (string, error) { @@ -509,7 +594,7 @@ func vaultSetup(args []string) error { return fmt.Errorf("all fields are required") } c := vwCreds{Email: email, MasterPassword: master, ClientID: clientID, ClientSecret: clientSecret} - if _, err := realRunner("vault", vaultPutArgs(vaultCurrentUser(), c), nil); err != nil { + if err := writeCreds(vaultCurrentUser(), c); err != nil { return fmt.Errorf("writing creds to your Vault path failed (scoped token present?): %w", err) } fmt.Fprintln(os.Stderr, "Stored. Verifying unlock…") @@ -545,6 +630,9 @@ func vaultGet(args []string) error { } writeOpLog(opRecord{User: user, Verb: "get", PID: os.Getpid(), PPID: os.Getppid(), ParentComm: parentComm(os.Getppid()), ItemName: o.name}) if o.json { + if !jsonToStdoutOK(stdoutIsTTY()) { + return fmt.Errorf("refusing to print a secret as JSON to a terminal; pipe it (e.g. | cat) or drop --json") + } fmt.Printf("{%q:%q}\n", o.field, val) return nil } diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index d7f845e0..55f01508 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -233,13 +233,101 @@ func TestStatusSummaryUnconfigured(t *testing.T) { } } -func TestVaultPutArgs(t *testing.T) { - got := vaultPutArgs("emo", vwCreds{Email: "e", MasterPassword: "m", ClientID: "ci", ClientSecret: "cs"}) +func TestVaultPatchPublicArgs(t *testing.T) { + got := vaultPatchPublicArgs("emo", "e@x.me", "user.ci") want := []string{"kv", "patch", "secret/workstation/claude-users/emo", - "vaultwarden_email=e", "vaultwarden_master_password=m", - "vaultwarden_client_id=ci", "vaultwarden_client_secret=cs"} + "vaultwarden_email=e@x.me", "vaultwarden_client_id=user.ci"} if !reflect.DeepEqual(got, want) { - t.Fatalf("vaultPutArgs = %v", got) + t.Fatalf("vaultPatchPublicArgs = %v", got) + } + for _, a := range got { + if strings.Contains(a, "master_password") || strings.Contains(a, "client_secret") { + t.Fatalf("secret key leaked into public argv: %v", got) + } + } +} + +func TestVaultPatchSecretArgsNoValueInArgv(t *testing.T) { + for _, key := range []string{"vaultwarden_master_password", "vaultwarden_client_secret"} { + got := vaultPatchSecretArgs("emo", key) + want := []string{"kv", "patch", "secret/workstation/claude-users/emo", key + "=-"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("vaultPatchSecretArgs(%q) = %v", key, got) + } + if got[len(got)-1] != key+"=-" { + t.Fatalf("secret value must be read from stdin (`%s=-`), got %v", key, got) + } + } +} + +// TestNoSecretInArgvAcrossFlow is the load-bearing security test: across the +// whole get flow (vault reads, bw config/status/login/unlock/get) NO secret +// value may appear in any command's argv — secrets travel via env/stdin only. +func TestNoSecretInArgvAcrossFlow(t *testing.T) { + uid := fmt.Sprintf("%d", os.Getuid()) + f := &fakeRunner{out: map[string]string{ + "vault kv get -field=vaultwarden_master_password secret/workstation/claude-users/emo": "SUPERSECRETPW", + "vault kv get -field=vaultwarden_client_id secret/workstation/claude-users/emo": "user.x", + "vault kv get -field=vaultwarden_client_secret secret/workstation/claude-users/emo": "CLIENTSEKRET", + "bw status": `{"status":"locked"}`, + "bw unlock": "SESSIONXYZ", + "bw get password github": "p@ss", + }} + if _, err := getValue(f.run, "emo", uid, getOpts{name: "github", field: "password"}); err != nil { + t.Fatalf("getValue: %v", err) + } + for _, call := range f.calls { + for _, arg := range call { + for _, s := range []string{"SUPERSECRETPW", "CLIENTSEKRET", "SESSIONXYZ"} { + if strings.Contains(arg, s) { + t.Errorf("secret %q leaked into argv: %v", s, call) + } + } + } + } + if !strings.Contains(strings.Join(f.lastEnv, "\n"), "BW_SESSION=SESSIONXYZ") { + t.Error("expected BW_SESSION in the bw get env (test would be vacuous otherwise)") + } +} + +func TestClipboardDecision(t *testing.T) { + cases := []struct { + stdoutTTY, stderrTTY bool + term, prog, want string + }{ + {false, true, "xterm-kitty", "", "stdout"}, + {true, true, "xterm-kitty", "", "clipboard"}, + {true, true, "dumb", "", "refuse"}, + {true, false, "xterm-kitty", "", "refuse"}, + } + for _, c := range cases { + if got := clipboardDecision(c.stdoutTTY, c.stderrTTY, c.term, c.prog); got != c.want { + t.Errorf("clipboardDecision(%v,%v,%q) = %q, want %q", c.stdoutTTY, c.stderrTTY, c.term, got, c.want) + } + } +} + +func TestJSONToStdoutOK(t *testing.T) { + if jsonToStdoutOK(true) { + t.Error("must refuse JSON secret on a terminal") + } + if !jsonToStdoutOK(false) { + t.Error("must allow JSON when piped") + } +} + +func TestBwNeedsLogin(t *testing.T) { + if !bwNeedsLogin(`{"status":"unauthenticated"}`) { + t.Error("unauthenticated → needs login") + } + if bwNeedsLogin(`{"status":"locked"}`) { + t.Error("locked → no login (just unlock)") + } + if bwNeedsLogin(`{"status":"unlocked"}`) { + t.Error("unlocked → no login") + } + if !bwNeedsLogin(`not json`) { + t.Error("unparseable → attempt login") } } From 15643d1f441a9f1eb8e3df5efd1cad25884f7e65 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:29:32 +0000 Subject: [PATCH 11/13] feat(cli): bare `homelab vault` help command --- cli/cmd_vault.go | 21 +++++++++++++++++++++ cli/cmd_vault_test.go | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index 21d0313b..bf270886 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -32,9 +32,30 @@ func vaultCommands() []Command { Summary: "current TOTP code for an item: vault code ", Run: vaultCode}, {Path: []string{"vault", "lock"}, Tier: TierWrite, Summary: "lock/log out the local bw session", Run: vaultLock}, + {Path: []string{"vault"}, Tier: TierRead, + Summary: "Vaultwarden access for your own vault (run `homelab vault` for help)", + Run: func([]string) error { fmt.Print(vaultHelp()); return nil }}, } } +// vaultHelp is shown for bare `homelab vault`. +func vaultHelp() string { + return `homelab vault — read YOUR OWN Vaultwarden logins (no-HITL after one-time setup) + + homelab vault setup one-time: store your master password + API key in your Vault path + homelab vault status configured / unlocked / reachable (no secrets) + homelab vault list [--search Q] list your item names (no secrets) + homelab vault get [--field password|username|uri|notes|totp] [--json] + TTY → clipboard (auto-clears); piped → stdout + homelab vault code current TOTP code + homelab vault lock lock / log out the local bw session + +Creds live only in your own Vault path; the admin never sees them. Identity is +your unix UID. Security model: docs/superpowers/specs/2026-06-24-homelab-vault-design.md +(note: anything running as your user can decrypt your vault — the accepted no-HITL trade). +` +} + const vwUserPathPrefix = "secret/workstation/claude-users/" // vwCreds is one user's Vaultwarden auth material, read from their Vault path. diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 55f01508..36aab1f4 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -331,6 +331,24 @@ func TestBwNeedsLogin(t *testing.T) { } } +func TestVaultHelpMentionsSecurity(t *testing.T) { + h := vaultHelp() + for _, want := range []string{"homelab vault get", "no-HITL", "your own", "setup"} { + if !strings.Contains(h, want) { + t.Errorf("vault help missing %q", want) + } + } +} + +func TestVaultBareGroupRegistered(t *testing.T) { + for _, c := range vaultCommands() { + if len(c.Path) == 1 && c.Path[0] == "vault" { + return + } + } + t.Fatal("bare `vault` help command not registered") +} + // getValue is the testable core: given a runner + opts, returns the secret value. func TestGetValueFlow(t *testing.T) { f := &fakeRunner{out: map[string]string{ From 64104e56e904056548ddb41686c97d55efc0e305 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:29:57 +0000 Subject: [PATCH 12/13] feat(devvm): install Bitwarden CLI for homelab vault --- scripts/workstation/setup-devvm.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/scripts/workstation/setup-devvm.sh b/scripts/workstation/setup-devvm.sh index e4265269..2969b803 100755 --- a/scripts/workstation/setup-devvm.sh +++ b/scripts/workstation/setup-devvm.sh @@ -71,6 +71,14 @@ if [[ -n "$want_t3" && "$(t3 --version 2>/dev/null | awk '{print $NF}' | sed 's/ log "npm: installing t3@$T3_TRACK ($want_t3)"; npm install -g "t3@$want_t3" >/dev/null fi +# 2c) Bitwarden CLI — backs `homelab vault` (per-user no-HITL Vaultwarden access). +# npm-global so every user's PATH resolves it. Pinned major; best-effort (a +# failure only disables `homelab vault`, nothing else on the box). +if ! command -v bw >/dev/null; then + log "npm: installing @bitwarden/cli (homelab vault backend)" + npm install -g "@bitwarden/cli@^2024" >/dev/null 2>&1 || log "WARN: @bitwarden/cli install failed; homelab vault unavailable" +fi + # 3) kubelogin (kubectl oidc-login) system-wide — NOT the apt 'kubelogin' (= Azure tool). # PINNED (not 'latest/download') so two fresh boxes built weeks apart are byte-identical. KUBELOGIN_VER="${KUBELOGIN_VER:-v1.36.2}" From e711b2f9715c11bdded515bbbfc0f752bf23f539 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Wed, 24 Jun 2026 10:31:32 +0000 Subject: [PATCH 13/13] feat(monitoring): homelab vault traceability alerts (TOTP-fetch + volume) Adds a Loki ruler group (lane=security -> #security) for the homelab vault op-log: VaultwardenTOTPFetched (every 2nd-factor fetch is visible) and VaultwardenFetchVolumeHigh (>100 fetches/10m backstop). The audit spine (Vault audit device, reads of secret/data/workstation/claude-users/*) is already captured. True CLI-bypass detection needs cross-stream correlation (follow-up). --- stacks/monitoring/modules/monitoring/loki.tf | 33 ++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/stacks/monitoring/modules/monitoring/loki.tf b/stacks/monitoring/modules/monitoring/loki.tf index cfb160bb..6c6b67ea 100644 --- a/stacks/monitoring/modules/monitoring/loki.tf +++ b/stacks/monitoring/modules/monitoring/loki.tf @@ -501,6 +501,39 @@ resource "kubernetes_config_map" "loki_alert_rules" { } }, ] + }, + { + # Vaultwarden vault CLI (`homelab vault`) traceability. The audit SPINE + # is the Vault audit device (reads of secret/data/workstation/claude-users/* + # are already captured in the vault-tail stream above). These add + # visibility/anomaly alerts off the per-user CLI op-log + # (`logger -t homelab-vault[-totp]` → devvm-journal). A true "Vault + # creds-read with NO matching CLI op-log = direct bypass" alert needs + # cross-stream correlation the Loki ruler can't express — tracked as a + # follow-up (small correlation CronJob). lane=security → #security. + name = "Vaultwarden vault CLI" + rules = [ + { + alert = "VaultwardenTOTPFetched" + expr = "sum by (user) (count_over_time({job=\"devvm-journal\", identifier=\"homelab-vault-totp\"} | logfmt [5m])) > 0" + for = "0m" + labels = { severity = "info", lane = "security" } + annotations = { + summary = "Vaultwarden TOTP (2nd factor) fetched via homelab vault by {{ $labels.user }}" + description = "A TOTP code was retrieved with `homelab vault code`. A stored TOTP co-located with its password collapses that downstream account's 2FA to 1FA under a same-UID compromise — confirm this fetch was expected." + } + }, + { + alert = "VaultwardenFetchVolumeHigh" + expr = "sum by (user) (count_over_time({job=\"devvm-journal\", identifier=\"homelab-vault\"} | logfmt | verb=~\"get|code\" [10m])) > 100" + for = "0m" + labels = { severity = "warning", lane = "security" } + annotations = { + summary = "Unusually high homelab vault fetch volume (>100/10m) for {{ $labels.user }}" + description = "A burst of credential fetches for one user — possible runaway loop or exfiltration. Cross-check the op-log parent process and the Vault audit stream (namespace=vault,container=audit-tail) for reads of secret/data/workstation/claude-users/{{ $labels.user }}." + } + }, + ] } ] })