Merge remote-tracking branch 'origin/master'
All checks were successful
ci/woodpecker/push/default Pipeline was successful
All checks were successful
ci/woodpecker/push/default Pipeline was successful
This commit is contained in:
commit
1d0388da12
5 changed files with 1073 additions and 0 deletions
663
cli/cmd_vault.go
Normal file
663
cli/cmd_vault.go
Normal file
|
|
@ -0,0 +1,663 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// 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/<user>) 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 <name> [--field password|username|uri|notes|totp] [--json]", Run: vaultGet},
|
||||
{Path: []string{"vault", "search"}, Tier: TierRead,
|
||||
Summary: "search your item names: vault search <query>", Run: vaultSearch},
|
||||
{Path: []string{"vault", "code"}, Tier: TierRead,
|
||||
Summary: "current TOTP code for an item: vault code <name>", 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 <name> [--field password|username|uri|notes|totp] [--json]
|
||||
TTY → clipboard (auto-clears); piped → stdout
|
||||
homelab vault code <name> 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.
|
||||
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()
|
||||
// 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/<pid>/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 }
|
||||
|
||||
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()) }
|
||||
|
||||
// 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"} }
|
||||
|
||||
// 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 != "" {
|
||||
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 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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/<ppid>/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 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
sess, err := bwUnlock(run, loginEnv)
|
||||
if err != nil {
|
||||
return session{}, err
|
||||
}
|
||||
return session{env: bwSecretEnv(appdata, creds, sess)}, nil
|
||||
}
|
||||
|
||||
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 <name> [--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)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
switch clipboardDecision(stdoutIsTTY(), stderrIsTTY(), os.Getenv("TERM"), os.Getenv("TERM_PROGRAM")) {
|
||||
case "stdout":
|
||||
fmt.Println(value)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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 <query>")
|
||||
}
|
||||
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>")
|
||||
}
|
||||
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 {
|
||||
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))
|
||||
_, logoutErr := realRunner("bw", []string{"logout"}, bwBaseEnv(appdata))
|
||||
if logoutErr == nil {
|
||||
fmt.Println("locked")
|
||||
}
|
||||
return nil // lock/logout best-effort; never error the caller
|
||||
}
|
||||
|
||||
// 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=" + email,
|
||||
"vaultwarden_client_id=" + clientID,
|
||||
}
|
||||
}
|
||||
|
||||
// vaultPatchSecretArgs writes ONE secret value via the `key=-` stdin form, so
|
||||
// the value never appears in argv (ps / /proc/<pid>/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)
|
||||
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')
|
||||
// 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) {
|
||||
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 := 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…")
|
||||
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()
|
||||
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 {
|
||||
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
|
||||
}
|
||||
emitSecret(val)
|
||||
return nil
|
||||
}
|
||||
|
||||
368
cli/cmd_vault_test.go
Normal file
368
cli/cmd_vault_test.go
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"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()")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockPath(t *testing.T) {
|
||||
if got := vaultLockPath("1001"); got != "/run/user/1001/homelab-vault.lock" {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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@x.me", "vaultwarden_client_id=user.ci"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
"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/<uid>/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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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 }}."
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue