diff --git a/cli/cmd_vault.go b/cli/cmd_vault.go index bf270886..6d35ba76 100644 --- a/cli/cmd_vault.go +++ b/cli/cmd_vault.go @@ -15,7 +15,7 @@ import ( // 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. +// docs/runbooks/homelab-vault-onboarding.md. func vaultCommands() []Command { return []Command{ {Path: []string{"vault", "setup"}, Tier: TierWrite, @@ -51,7 +51,7 @@ func vaultHelp() string { 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 +your unix UID. Security model: docs/runbooks/homelab-vault-onboarding.md (note: anything running as your user can decrypt your vault — the accepted no-HITL trade). ` } @@ -128,6 +128,53 @@ 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()) } +// scopedTokenPath is where claude-auth-sync keeps the user's scoped Vault token. +// MUST match CAS_VAULT_TOKEN_FILE in scripts/workstation/claude-auth-sync.sh. +func scopedTokenPath(home string) string { + return home + "/.config/claude-auth-sync/vault-token" +} + +// vaultTokenSource decides which Vault token the `vault` child processes should +// use. Precedence: an explicit $VAULT_TOKEN, then a native ~/.vault-token (what +// admins carry), then the per-user scoped token claude-auth-sync maintains at +// scopedTokenPath(HOME) (policy workstation-claude-, which grants exactly +// the create/read/update this tool needs on the user's own path). Returns the +// token to export — "" when nothing must be exported because the vault CLI reads +// the ambient credential natively — plus a source tag for tests/logging. +func vaultTokenSource(envToken string, haveVaultTokenFile bool, scopedToken string) (token, source string) { + switch { + case envToken != "": + return "", "env" + case haveVaultTokenFile: + return "", "file" + default: + if t := strings.TrimSpace(scopedToken); t != "" { + return t, "scoped" + } + return "", "none" + } +} + +// fileNonEmpty reports whether path exists and has content. +func fileNonEmpty(path string) bool { + fi, err := os.Stat(path) + return err == nil && fi.Size() > 0 +} + +// ensureVaultToken wires vaultTokenSource to the real environment: when the user +// has no ambient Vault credential, it exports the claude-auth-sync scoped token +// so the `vault` child processes authenticate as workstation-claude-. It +// is idempotent and safe for admins, whose explicit $VAULT_TOKEN / ~/.vault-token +// take precedence and are left untouched. +func ensureVaultToken() { + home := os.Getenv("HOME") + scoped, _ := os.ReadFile(scopedTokenPath(home)) + tok, src := vaultTokenSource(os.Getenv("VAULT_TOKEN"), home != "" && fileNonEmpty(home+"/.vault-token"), string(scoped)) + if src == "scoped" { + os.Setenv("VAULT_TOKEN", tok) + } +} + // 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 { @@ -157,10 +204,10 @@ func bwSecretEnv(appdata string, c vwCreds, session string) []string { return env } -func bwLoginArgs() []string { return []string{"login", "--apikey"} } -func bwUnlockArgs() []string { return []string{"unlock", "--passwordenv", "BW_PASSWORD", "--raw"} } +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 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). @@ -443,6 +490,7 @@ func runList(run cmdRunner, user, uid, search string) ([]string, error) { func vaultList(args []string) error { hardenProcess() + ensureVaultToken() search := "" for i := 0; i < len(args); i++ { if args[i] == "--search" && i+1 < len(args) { @@ -477,6 +525,7 @@ func vaultSearch(args []string) error { func vaultCode(args []string) error { hardenProcess() + ensureVaultToken() if len(args) == 0 { return fmt.Errorf("usage: homelab vault code ") } @@ -516,6 +565,7 @@ func statusSummary(run cmdRunner, user, uid string) string { func vaultStatus(args []string) error { hardenProcess() + ensureVaultToken() uid := vaultCurrentUID() unlock, err := withUserLock(uid) if err != nil { @@ -542,32 +592,61 @@ func vaultLock(args []string) error { return nil // lock/logout best-effort; never error the caller } -// vaultPatchPublicArgs writes the non-secret identifiers via argv. Neither the +// kvWriteVerb selects the KV write semantics. merge=true → `kv patch -method=rw` +// (read-modify-write: needs only read+update, NOT the `patch` capability the +// scoped workstation-claude- policy lacks, and preserves co-located keys +// such as claude-auth-sync's claude_ai_oauth_json). merge=false → `kv put` +// (creates the path on first use, before any sibling keys exist). +func kvWriteVerb(merge bool) []string { + if merge { + return []string{"kv", "patch", "-method=rw"} + } + return []string{"kv", "put"} +} + +// vaultWritePublicArgs 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, - } +func vaultWritePublicArgs(merge bool, user, email, clientID string) []string { + return append(kvWriteVerb(merge), 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//cmdline). The value is fed -// on stdin by realRunnerStdin. -func vaultPatchSecretArgs(user, key string) []string { - return []string{"kv", "patch", vwCredsPath(user), key + "=-"} +// vaultWriteSecretArgs writes ONE secret value via the `key=-` stdin form, so the +// value never appears in argv (ps / /proc//cmdline). Fed on stdin by +// realRunnerStdin. +func vaultWriteSecretArgs(merge bool, user, key string) []string { + return append(kvWriteVerb(merge), 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 { +// credsPathExists reports whether the user's KV path already holds data. Used to +// pick create (`kv put`) vs merge (`kv patch -method=rw`) for the first write: +// claude-auth-sync usually creates the path first (Claude OAuth backup), but a +// user could run `homelab vault setup` before that ever happens. +func credsPathExists(run cmdRunner, user string) bool { + _, err := run("vault", []string{"kv", "get", "-format=json", vwCredsPath(user)}, nil) + return err == nil +} + +// cmdRunnerStdin is realRunnerStdin's shape, injected so writeCreds is testable. +type cmdRunnerStdin func(name string, argv, envv []string, stdin string) (string, error) + +// writeCreds stores all four fields in the user's Vault path using only the +// capabilities the scoped policy grants (create/read/update — NOT `patch`). The +// first (public) write creates the path when absent; the two real secrets then +// merge in via read-modify-write so the public keys — and any claude-auth-sync +// keys already present — survive. Secret values travel on stdin, never argv. +func writeCreds(run cmdRunner, runStdin cmdRunnerStdin, user string, c vwCreds) error { + merge := credsPathExists(run, user) + if _, err := run("vault", vaultWritePublicArgs(merge, user, c.Email, c.ClientID), nil); err != nil { return err } - if _, err := realRunnerStdin("vault", vaultPatchSecretArgs(user, "vaultwarden_master_password"), nil, c.MasterPassword); err != nil { + // The path now exists regardless of the branch above → merge the secrets in. + if _, err := runStdin("vault", vaultWriteSecretArgs(true, 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 { + if _, err := runStdin("vault", vaultWriteSecretArgs(true, user, "vaultwarden_client_secret"), nil, c.ClientSecret); err != nil { return err } return nil @@ -593,6 +672,7 @@ func promptLine(prompt string) (string, error) { func vaultSetup(args []string) error { hardenProcess() + ensureVaultToken() 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: ") @@ -615,7 +695,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 := writeCreds(vaultCurrentUser(), c); err != nil { + if err := writeCreds(realRunner, realRunnerStdin, 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…") @@ -634,6 +714,7 @@ func vaultSetup(args []string) error { func vaultGet(args []string) error { hardenProcess() + ensureVaultToken() o, err := parseGetArgs(args) if err != nil { return err @@ -660,4 +741,3 @@ func vaultGet(args []string) error { emitSecret(val) return nil } - diff --git a/cli/cmd_vault_test.go b/cli/cmd_vault_test.go index 36aab1f4..4f583b95 100644 --- a/cli/cmd_vault_test.go +++ b/cli/cmd_vault_test.go @@ -70,7 +70,7 @@ func (f *fakeRunner) run(name string, argv, envv []string) (string, error) { 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_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", @@ -233,12 +233,96 @@ func TestStatusSummaryUnconfigured(t *testing.T) { } } -func TestVaultPatchPublicArgs(t *testing.T) { - got := vaultPatchPublicArgs("emo", "e@x.me", "user.ci") - want := []string{"kv", "patch", "secret/workstation/claude-users/emo", +func TestEnsureVaultTokenSetsScopedFallback(t *testing.T) { + dir := t.TempDir() + cfg := dir + "/.config/claude-auth-sync" + if err := os.MkdirAll(cfg, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfg+"/vault-token", []byte("SCOPED-TOK\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", dir) + t.Setenv("VAULT_TOKEN", "") // no ambient token + + ensureVaultToken() + if got := os.Getenv("VAULT_TOKEN"); got != "SCOPED-TOK" { + t.Fatalf("VAULT_TOKEN = %q, want scoped fallback to be exported", got) + } +} + +func TestEnsureVaultTokenKeepsExplicitEnv(t *testing.T) { + dir := t.TempDir() + cfg := dir + "/.config/claude-auth-sync" + if err := os.MkdirAll(cfg, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(cfg+"/vault-token", []byte("SCOPED-TOK"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("HOME", dir) + t.Setenv("VAULT_TOKEN", "ADMIN-TOK") + + ensureVaultToken() + if got := os.Getenv("VAULT_TOKEN"); got != "ADMIN-TOK" { + t.Fatalf("VAULT_TOKEN = %q, must not override an explicit token", got) + } +} + +func TestScopedTokenPath(t *testing.T) { + if got := scopedTokenPath("/home/emo"); got != "/home/emo/.config/claude-auth-sync/vault-token" { + t.Fatalf("scopedTokenPath = %q", got) + } +} + +func TestVaultTokenSource(t *testing.T) { + // Precedence: explicit $VAULT_TOKEN > ~/.vault-token (vault CLI native) > + // the claude-auth-sync per-user scoped token. This is what lets a non-admin + // workstation user (no ambient token) reach their own Vault path. + cases := []struct { + name string + env string + haveVaultToken bool + scoped string + wantTok, wantSrc string + }{ + {"explicit env wins", "abc", true, "S", "", "env"}, + {"vault-token file used natively", "", true, "S", "", "file"}, + {"scoped fallback for non-admin", "", false, "S-TOK", "S-TOK", "scoped"}, + {"scoped value is trimmed", "", false, " S-TOK\n", "S-TOK", "scoped"}, + {"whitespace-only scoped is no token", "", false, " \n", "", "none"}, + {"nothing configured", "", false, "", "", "none"}, + } + for _, c := range cases { + tok, src := vaultTokenSource(c.env, c.haveVaultToken, c.scoped) + if tok != c.wantTok || src != c.wantSrc { + t.Errorf("%s: vaultTokenSource(%q,%v,%q) = (%q,%q), want (%q,%q)", + c.name, c.env, c.haveVaultToken, c.scoped, tok, src, c.wantTok, c.wantSrc) + } + } +} + +func TestKvWriteVerb(t *testing.T) { + // merge=true → read-modify-write patch (needs only read+update, NOT the + // `patch` capability the scoped workstation policy lacks). + if got := kvWriteVerb(true); !reflect.DeepEqual(got, []string{"kv", "patch", "-method=rw"}) { + t.Fatalf("kvWriteVerb(true) = %v", got) + } + // merge=false → put (creates the path on first use) + if got := kvWriteVerb(false); !reflect.DeepEqual(got, []string{"kv", "put"}) { + t.Fatalf("kvWriteVerb(false) = %v", got) + } +} + +func TestVaultWritePublicArgs(t *testing.T) { + got := vaultWritePublicArgs(true, "emo", "e@x.me", "user.ci") + want := []string{"kv", "patch", "-method=rw", "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) + t.Fatalf("vaultWritePublicArgs(merge) = %v", got) + } + if got := vaultWritePublicArgs(false, "emo", "e@x.me", "user.ci"); got[0] != "kv" || got[1] != "put" { + t.Fatalf("vaultWritePublicArgs(create) must use `kv put`, got %v", got) } for _, a := range got { if strings.Contains(a, "master_password") || strings.Contains(a, "client_secret") { @@ -247,12 +331,12 @@ func TestVaultPatchPublicArgs(t *testing.T) { } } -func TestVaultPatchSecretArgsNoValueInArgv(t *testing.T) { +func TestVaultWriteSecretArgsNoValueInArgv(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 + "=-"} + got := vaultWriteSecretArgs(true, "emo", key) + want := []string{"kv", "patch", "-method=rw", "secret/workstation/claude-users/emo", key + "=-"} if !reflect.DeepEqual(got, want) { - t.Fatalf("vaultPatchSecretArgs(%q) = %v", key, got) + t.Fatalf("vaultWriteSecretArgs(%q) = %v", key, got) } if got[len(got)-1] != key+"=-" { t.Fatalf("secret value must be read from stdin (`%s=-`), got %v", key, got) @@ -260,6 +344,90 @@ func TestVaultPatchSecretArgsNoValueInArgv(t *testing.T) { } } +// recStdin records a stdin-bearing call for assertions. +type recStdin struct { + argv []string + stdin string +} + +// TestWriteCredsCreatesThenMerges: when the path is ABSENT the first (public) +// write must `kv put` (create), and the two secrets must merge via patch -rw +// with values on stdin only — never the buggy plain `kv patch` (needs `patch`). +func TestWriteCredsCreatesThenMerges(t *testing.T) { + var calls [][]string + var stdinCalls []recStdin + run := func(name string, argv, envv []string) (string, error) { + calls = append(calls, append([]string{name}, argv...)) + if len(argv) >= 2 && argv[0] == "kv" && argv[1] == "get" { + return "", fmt.Errorf("no value found") // path absent + } + return "", nil + } + runStdin := func(name string, argv, envv []string, stdin string) (string, error) { + stdinCalls = append(stdinCalls, recStdin{append([]string{name}, argv...), stdin}) + return "", nil + } + c := vwCreds{Email: "e@x.me", MasterPassword: "PW", ClientID: "user.ci", ClientSecret: "CS"} + if err := writeCreds(run, runStdin, "emo", c); err != nil { + t.Fatalf("writeCreds: %v", err) + } + var sawPut, sawPlainPatch bool + for _, cl := range calls { + j := strings.Join(cl, " ") + if strings.Contains(j, "kv put") { + sawPut = true + } + if strings.Contains(j, "kv patch") && !strings.Contains(j, "-method=rw") { + sawPlainPatch = true + } + } + if !sawPut { + t.Fatalf("path absent → public write must be `kv put`; calls=%v", calls) + } + if sawPlainPatch { + t.Fatalf("must never use plain `kv patch` (needs `patch` capability); calls=%v", calls) + } + if len(stdinCalls) != 2 { + t.Fatalf("want 2 stdin secret writes, got %d", len(stdinCalls)) + } + for _, sc := range stdinCalls { + if !strings.Contains(strings.Join(sc.argv, " "), "kv patch -method=rw") { + t.Errorf("secret write must use patch -method=rw: %v", sc.argv) + } + for _, a := range sc.argv { + if strings.Contains(a, "PW") || strings.Contains(a, "CS") { + t.Errorf("secret leaked into argv: %v", sc.argv) + } + } + } + if stdinCalls[0].stdin != "PW" || stdinCalls[1].stdin != "CS" { + t.Errorf("stdin values wrong: %q,%q", stdinCalls[0].stdin, stdinCalls[1].stdin) + } +} + +// TestWriteCredsMergesWhenPresent: when the path EXISTS, every write must merge +// (patch -rw) — a `kv put` would wipe sibling keys (e.g. claude_ai_oauth_json). +func TestWriteCredsMergesWhenPresent(t *testing.T) { + var calls [][]string + run := func(name string, argv, envv []string) (string, error) { + calls = append(calls, append([]string{name}, argv...)) + return "{}", nil // get succeeds → path exists + } + runStdin := func(name string, argv, envv []string, stdin string) (string, error) { + calls = append(calls, append([]string{name}, argv...)) + return "", nil + } + c := vwCreds{Email: "e@x.me", MasterPassword: "PW", ClientID: "user.ci", ClientSecret: "CS"} + if err := writeCreds(run, runStdin, "emo", c); err != nil { + t.Fatalf("writeCreds: %v", err) + } + for _, cl := range calls { + if strings.Contains(strings.Join(cl, " "), "kv put") { + t.Fatalf("path exists → must NOT `kv put` (wipes siblings): %v", cl) + } + } +} + // 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. @@ -267,8 +435,8 @@ 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", + "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", @@ -353,8 +521,8 @@ func TestVaultBareGroupRegistered(t *testing.T) { 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", + "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", diff --git a/docs/runbooks/homelab-vault-onboarding.md b/docs/runbooks/homelab-vault-onboarding.md new file mode 100644 index 00000000..61d323ab --- /dev/null +++ b/docs/runbooks/homelab-vault-onboarding.md @@ -0,0 +1,121 @@ +# `homelab vault` onboarding (per-user Vaultwarden access) + +## Scope + +`homelab vault` gives each devvm roster user no-HITL access to **their own** +Vaultwarden vault (and any Organization Collection shared with their account) +from the command line. It shells out to the official `bw` CLI; the user's +Vaultwarden credentials live only in their isolated Vault path +`secret/workstation/claude-users/` and are decrypted as that OS user — +the admin never sees them. + +```text +homelab vault setup one-time: store VW email + master password + API key +homelab vault status configured / unlocked / reachable (no secrets) +homelab vault list [--search Q] item names (no secrets) +homelab vault get [--field password|username|uri|notes|totp] [--json] +homelab vault code current TOTP code +homelab vault lock lock / log out the local bw session +``` + +## How auth works (why a non-admin can use it) + +`homelab vault` runs `vault` as the calling user. It resolves a Vault token in +this order (`ensureVaultToken`, `cli/cmd_vault.go`): + +1. an explicit `$VAULT_TOKEN`, then +2. a native `~/.vault-token` (what admins carry), then +3. the per-user **scoped token** that `claude-auth-sync` maintains at + `~/.config/claude-auth-sync/vault-token` (policy `workstation-claude-`). + +That scoped policy grants exactly `create`/`read`/`update` on the user's own +`secret/workstation/claude-users/` path — no `patch` capability — so the +tool writes with `vault kv patch -method=rw` (read-modify-write), falling back to +`kv put` only when the path does not exist yet. This preserves the +`claude_ai_oauth_json` key that [claude-auth-sync](claude-auth-renew-workstation.md) +co-locates there. (Both bugs that previously made this admin-only were fixed +2026-06-27.) + +## Prerequisites (per user) + +- The user is in `scripts/workstation/roster.yaml` and the **vault** stack has + been applied → their `workstation-claude-` policy exists. +- The user's workstation was provisioned (`setup-devvm.sh`) → their scoped Vault + token exists at `~/.config/claude-auth-sync/vault-token`. +- `bw` is installed **system-wide** at `/usr/bin/bw` (see below). +- The user has a Vaultwarden account at `https://vaultwarden.viktorbarzin.me` + (self-service signup is open; admin panel is disabled). + +## One-time admin steps (devvm) + +`bw` must be system-wide so every user resolves it (it is a Node script, and +`node` is already system-wide at `/usr/bin/node`). `setup-devvm.sh` installs it +to the npm `/usr` prefix; the guard checks the **system** path, not +`command -v bw` (an admin's own `~/.local/bin/bw` used to mask the system +install, leaving non-admins with no backend). To install on a running box: + +```bash +sudo npm install -g --prefix /usr "@bitwarden/cli@^2024" +bw --version # confirm /usr/bin/bw resolves +``` + +After landing a `cli/` change, rebuild the binary so users pick it up: + +```bash +sudo bash -c 'cd /home/wizard/code/infra/cli && \ + go build -ldflags "-X main.version=$(git -C /home/wizard/code/infra describe --tags --always 2>/dev/null || echo dev)" \ + -o /usr/local/bin/homelab .' +``` + +(or just re-run `scripts/workstation/setup-devvm.sh` as root, which rebuilds it.) + +## User onboarding + +The user runs these as themselves. The master password / API key are entered +interactively (never on the command line) and stored only in the user's Vault +path. + +1. In the Vaultwarden web vault → **Settings → Security → Keys → View API key**, + copy the `client_id` (`user.xxxx`) and `client_secret`. +2. Configure: + + ```bash + homelab vault setup # prompts: VW email, API client_id/secret, master password + homelab vault status # → "vault: configured, unlocked, reachable ✓" + homelab vault list # item names (own vault + any shared Collections) + ``` + +## Shared-Collection access (sharing passwords with a user) + +`homelab vault` surfaces Organization Collection items automatically once the +user's Vaultwarden account is a confirmed member. These steps are done by the +vault owner in the **Vaultwarden web UI** (they need the owner's master +password — not an infra/Terraform operation): + +1. Create or reuse an **Organization** and a **Collection** of shared logins. +2. **Invite** the user's Vaultwarden account to the Organization, granting + **"Can view"** on that Collection (least privilege). +3. The user accepts the email invite and confirms membership. +4. The user runs `homelab vault list` — the shared items now appear alongside + their own (a `homelab vault status` sync picks them up). + +## Security model (the no-HITL trade) + +Identity is the kernel UID. Anything running as the user can decrypt the user's +vault — this is the accepted trade for no-human-in-the-loop fetches. Secrets +never appear in `argv` (passed via env or stdin), core dumps are disabled, TOTP +fetches are logged to syslog/Loki, and on a TTY values go to the clipboard +(auto-clearing) rather than scrollback. The admin's Vault token is never used by +a non-admin: each user authenticates with their own scoped token. + +## Verification + +```bash +# the scoped token carries the right policy +VAULT_TOKEN="$(sudo cat /home//.config/claude-auth-sync/vault-token)" \ + vault token lookup -format=json | jq '.data.display_name, .data.policies' +# → "token-devvm-claude-auth-", [..., "workstation-claude-"] + +sudo -u -i bw --version # /usr/bin/bw resolves for the user +sudo -u -i homelab vault status +``` diff --git a/scripts/workstation/setup-devvm.sh b/scripts/workstation/setup-devvm.sh index 2969b803..02bd9257 100755 --- a/scripts/workstation/setup-devvm.sh +++ b/scripts/workstation/setup-devvm.sh @@ -72,11 +72,14 @@ if [[ -n "$want_t3" && "$(t3 --version 2>/dev/null | awk '{print $NF}' | sed 's/ 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" +# Install SYSTEM-WIDE (npm prefix /usr → /usr/bin/bw) so EVERY user's PATH +# resolves it. The guard tests the SYSTEM path, NOT `command -v bw`: the +# latter is satisfied by an admin's own ~/.local/bin/bw and would skip the +# system install, leaving non-admins (emo, anca, …) with no backend. Pinned +# major; best-effort (a failure only disables `homelab vault`). +if [ ! -x /usr/bin/bw ] && [ ! -x /usr/local/bin/bw ]; then + log "npm: installing @bitwarden/cli system-wide (homelab vault backend)" + npm install -g --prefix /usr "@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).