vault: distinguish Vaultwarden vs HashiCorp Vault, add vault kv

`homelab vault` only spoke to Vaultwarden (the password manager), but the
name reads as HashiCorp Vault (the infra secrets store — actually OpenBao
here). Make the two unmistakable and support both.

Distinction (no breakage — the existing Vaultwarden verbs are unchanged):
- bare `homelab vault` help now LEADS with the two-stores split;
- every verb summary is tagged `[vaultwarden]` or `[hashicorp-vault]`;
- HashiCorp Vault/OpenBao lives under a clearly-named `vault kv` group.

New `vault kv` (HashiCorp Vault / OpenBao, the secret/… KV store):
- `kv get <path> [--field K]` — read; --field → one value (TTY-aware
  clipboard/stdout), no field → full secret JSON (refuses a bare TTY).
- `kv list <path>` — list sub-paths (no values).
- `kv put <path> <key>` — write one key; value via stdin (piped or
  no-echo prompt, never argv); creates the path or merges (never
  clobbers siblings; uses kv patch -method=rw so no `patch` cap needed).

Critical: `kv` uses the caller's OWN Vault token (OIDC ~/.vault-token /
$VAULT_TOKEN), NOT the per-user scoped Vaultwarden token (bound only to
claude-users/<user>, which would 403 elsewhere) — handlers set VAULT_ADDR
but never inject the scoped token. Access is whatever the policy grants.

Logic in cmd_vault_kv.go (pure cores extractKVData/parseKVList/arg
builders/kvGet/List/Put; file header documents the credential split).
CLI v0.11.0. Tests: no value in put argv, create-then-merge, KV-v2
envelope strip, help names both systems. Verified e2e against live Vault
(read key-names-only + a scratch put/merge/cleanup).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Viktor Barzin 2026-06-28 11:09:33 +00:00
parent a1cf7ccaf6
commit e03e4719ad
6 changed files with 492 additions and 21 deletions

View file

@ -244,6 +244,27 @@ vault wouldn't show up until the next login. The sync is **best-effort**: a
transient failure warns on stderr and falls back to the cached vault rather than
failing the read.
### v0.11 — `vault kv` (HashiCorp Vault / OpenBao infra secrets)
`homelab vault` now fronts **two unrelated stores**, made explicit in the bare
`homelab vault` help and via `[vaultwarden]` / `[hashicorp-vault]` summary tags:
- **Vaultwarden** — your personal password manager (`vault get/list/code/…`, unchanged).
- **HashiCorp Vault / OpenBao** — homelab infra secrets, the `secret/…` KV store, under `vault kv`.
| Command | Tier | What it does |
| --- | --- | --- |
| `vault kv get <path> [--field K]` | read | read a secret: `--field K` → one value (TTY-aware clipboard/stdout); no field → all fields as JSON (refuses a bare TTY) |
| `vault kv list <path>` | read | list sub-paths under `<path>` (no values) |
| `vault kv put <path> <key>` | write | write one key; **value via stdin** (piped or no-echo prompt, never argv); creates the path or **merges** (never clobbers siblings) |
**Different credentials:** the Vaultwarden verbs use the per-user *scoped* token
(bound to `claude-users/<user>`); `vault kv` uses your **own** Vault token
(`vault login -method=oidc``~/.vault-token`, or `$VAULT_TOKEN`) — the kv
handlers set `VAULT_ADDR` but never inject the scoped token (which would 403 off
its own path). Access is whatever your policy grants. Writes are merge-only;
`put` (replace) / `delete` are out of scope — use the raw `vault` CLI.
## Build / install
Built from source to `/usr/local/bin/homelab` during devvm provisioning

View file

@ -1 +1 @@
v0.10.1
v0.11.0

View file

@ -18,31 +18,41 @@ import (
// decryption is done by the official `bw` CLI. See
// docs/runbooks/homelab-vault-onboarding.md.
func vaultCommands() []Command {
return []Command{
cmds := []Command{
// Vaultwarden — your personal password manager (logins/passwords/TOTP).
{Path: []string{"vault", "setup"}, Tier: TierWrite,
Summary: "one-time: store your Vaultwarden master password + API key in your Vault path", Run: vaultSetup},
Summary: "[vaultwarden] one-time: store your 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},
Summary: "[vaultwarden] 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},
Summary: "[vaultwarden] 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] [--all]", Run: vaultGet},
Summary: "[vaultwarden] fetch one login: vault get <name> [--field password|username|uri|notes|totp] [--json] [--all]", Run: vaultGet},
{Path: []string{"vault", "search"}, Tier: TierRead,
Summary: "search your item names: vault search <query>", Run: vaultSearch},
Summary: "[vaultwarden] 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},
Summary: "[vaultwarden] 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},
Summary: "[vaultwarden] 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)",
Summary: "two stores: Vaultwarden (logins) + HashiCorp Vault/OpenBao kv (infra secrets) — run `homelab vault` for help",
Run: func([]string) error { fmt.Print(vaultHelp()); return nil }},
}
// HashiCorp Vault / OpenBao — homelab INFRA secrets (the secret/… KV store).
return append(cmds, vaultKVCommands()...)
}
// vaultHelp is shown for bare `homelab vault`.
// vaultHelp is shown for bare `homelab vault`. It LEADS with the distinction
// between the two unrelated "vaults" this command fronts, because the name
// collides: Vaultwarden (a password manager) vs HashiCorp Vault / OpenBao (the
// infra secrets store).
func vaultHelp() string {
return `homelab vault read YOUR OWN Vaultwarden logins (no-HITL after one-time setup)
return `homelab vault two different secret stores under one command:
Vaultwarden your personal PASSWORD MANAGER (logins / passwords / TOTP)
HashiCorp Vault / OpenBao homelab INFRA secrets (the secret/ KV store) 'vault kv '
Vaultwarden (reads YOUR OWN vault; 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)
@ -53,8 +63,13 @@ func vaultHelp() string {
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/runbooks/homelab-vault-onboarding.md
HashiCorp Vault / OpenBao (infra secrets; uses your own OIDC vault token)
homelab vault kv get <path> [--field K] read an infra KV secret
homelab vault kv list <path> list sub-paths
homelab vault kv put <path> <key> write one key (value via stdin)
Vaultwarden creds live only in your own Vault path; the admin never sees them.
Security model: docs/runbooks/homelab-vault-onboarding.md
(note: anything running as your user can decrypt your vault the accepted no-HITL trade).
`
}

248
cli/cmd_vault_kv.go Normal file
View file

@ -0,0 +1,248 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"strings"
)
// The `vault kv` verbs talk to HashiCorp Vault / OpenBao — the homelab INFRA
// secrets store (the `secret/…` KV-v2 mount at vault.viktorbarzin.me) — NOT
// Vaultwarden. They are a thin, TTY-aware wrapper over the `vault` CLI that adds
// the same conveniences as the Vaultwarden verbs: a self-defaulted VAULT_ADDR
// (so non-login agent shells work) and clipboard/refuse-on-TTY secret handling.
//
// CREDENTIALS DIFFER FROM THE VAULTWARDEN VERBS. Those use the per-user *scoped*
// token (bound only to secret/workstation/claude-users/<user>). A general kv read
// of e.g. secret/viktor must use the caller's OWN Vault token (the OIDC
// ~/.vault-token or an explicit $VAULT_TOKEN) — the scoped token has `deny`
// everywhere else and would 403. So the kv handlers call ensureVaultAddr() to
// guarantee VAULT_ADDR but deliberately do NOT call ensureVaultToken() (which
// injects the scoped token). Access is then whatever the caller's policy grants.
func vaultKVCommands() []Command {
return []Command{
{Path: []string{"vault", "kv", "get"}, Tier: TierRead,
Summary: "[hashicorp-vault] read an infra KV secret: vault kv get <path> [--field K]", Run: vaultKVGet},
{Path: []string{"vault", "kv", "list"}, Tier: TierRead,
Summary: "[hashicorp-vault] list infra KV sub-paths: vault kv list <path>", Run: vaultKVList},
{Path: []string{"vault", "kv", "put"}, Tier: TierWrite,
Summary: "[hashicorp-vault] write one KV key (value via stdin): vault kv put <path> <key>", Run: vaultKVPut},
{Path: []string{"vault", "kv"}, Tier: TierRead,
Summary: "[hashicorp-vault] infra secrets (run `homelab vault kv` for help)",
Run: func([]string) error { fmt.Print(vaultKVHelp()); return nil }},
}
}
func vaultKVHelp() string {
return `homelab vault kv HashiCorp Vault / OpenBao (homelab INFRA secrets, the secret/ KV store)
homelab vault kv get <path> [--field K] read a secret
--field K one value (TTY clipboard; piped stdout)
no --field all fields as JSON (piped only)
homelab vault kv list <path> list sub-paths under <path> (no values)
homelab vault kv put <path> <key> write one key; value read from stdin
(piped, or no-echo prompt); merges never clobbers siblings
Uses YOUR Vault token (vault login -method=oidc ~/.vault-token); access is
whatever your policy grants. This is NOT Vaultwarden for your personal logins
use 'homelab vault get' (see 'homelab vault').
`
}
// --- arg builders (pure; values never travel via argv) --------------------
func vaultKVGetFieldArgs(path, field string) []string {
return []string{"kv", "get", "-field=" + field, path}
}
func vaultKVGetJSONArgs(path string) []string { return []string{"kv", "get", "-format=json", path} }
func vaultKVListArgs(path string) []string { return []string{"kv", "list", "-format=json", path} }
// vaultKVPutArgs builds the write argv. merge=true → `kv patch -method=rw`
// (read-modify-write: merges, needs only read+update — not the `patch` capability
// — and preserves sibling keys); merge=false → `kv put` (creates the path on
// first write). The value is ALWAYS read from stdin via the `<key>=-` form, so it
// never appears in argv (visible via ps / /proc/<pid>/cmdline to same-UID procs).
func vaultKVPutArgs(merge bool, path, key string) []string {
return append(kvWriteVerb(merge), path, key+"=-")
}
// --- pure parsers ----------------------------------------------------------
// extractKVData returns the inner secret object from a `vault kv get -format=json`
// envelope (`{"data":{"data":{…},"metadata":{…}}}`), dropping the metadata/request
// wrapper so only the secret's own key→value data is emitted.
func extractKVData(jsonOut string) (string, error) {
var env struct {
Data struct {
Data json.RawMessage `json:"data"`
} `json:"data"`
}
if err := json.Unmarshal([]byte(jsonOut), &env); err != nil {
return "", fmt.Errorf("parse vault kv json: %w", err)
}
if len(env.Data.Data) == 0 {
return "", fmt.Errorf("no secret data at that path")
}
return string(env.Data.Data), nil
}
// parseKVList parses the JSON array `vault kv list -format=json` prints.
func parseKVList(jsonOut string) ([]string, error) {
var keys []string
if err := json.Unmarshal([]byte(jsonOut), &keys); err != nil {
return nil, fmt.Errorf("parse vault kv list json: %w", err)
}
return keys, nil
}
// --- testable cores (injected cmdRunner) -----------------------------------
func kvGetField(run cmdRunner, path, field string) (string, error) {
return run("vault", vaultKVGetFieldArgs(path, field), nil)
}
func kvGetJSON(run cmdRunner, path string) (string, error) {
out, err := run("vault", vaultKVGetJSONArgs(path), nil)
if err != nil {
return "", err
}
return extractKVData(out)
}
func kvList(run cmdRunner, path string) ([]string, error) {
out, err := run("vault", vaultKVListArgs(path), nil)
if err != nil {
return nil, err
}
return parseKVList(out)
}
// kvPathExists reports whether the KV path already holds data, to pick create
// (`kv put`) vs merge (`kv patch -method=rw`) — so a write never clobbers
// sibling keys on an existing path.
func kvPathExists(run cmdRunner, path string) bool {
_, err := run("vault", vaultKVGetJSONArgs(path), nil)
return err == nil
}
// kvPut writes one key, creating the path when absent and merging when present.
// The value travels on stdin only (never argv).
func kvPut(run cmdRunner, runStdin cmdRunnerStdin, path, key, value string) error {
merge := kvPathExists(run, path)
_, err := runStdin("vault", vaultKVPutArgs(merge, path, key), nil, value)
return err
}
// --- handlers --------------------------------------------------------------
func vaultKVGet(args []string) error {
hardenProcess()
ensureVaultAddr() // own token, NOT the scoped one (see file header)
var path, field string
for i := 0; i < len(args); i++ {
a := args[i]
switch {
case a == "--field" && i+1 < len(args):
field = args[i+1]
i++
case strings.HasPrefix(a, "--field="):
field = strings.TrimPrefix(a, "--field=")
case !strings.HasPrefix(a, "-") && path == "":
path = a
}
}
if path == "" {
return fmt.Errorf("usage: homelab vault kv get <path> [--field <key>]")
}
if field != "" {
val, err := kvGetField(realRunner, path, field)
if err != nil {
return err
}
emitSecret(val) // TTY-aware: clipboard on a terminal, stdout when piped
return nil
}
// No --field → the whole secret. All values, so refuse a bare TTY (like
// `vault get --json`): pick a --field for the clipboard path, or pipe it.
if !jsonToStdoutOK(stdoutIsTTY()) {
return fmt.Errorf("refusing to print all KV fields as JSON to a terminal; use --field <key>, or pipe it (e.g. | jq)")
}
out, err := kvGetJSON(realRunner, path)
if err != nil {
return err
}
fmt.Println(out)
return nil
}
func vaultKVList(args []string) error {
ensureVaultAddr()
var path string
for _, a := range args {
if !strings.HasPrefix(a, "-") {
path = a
break
}
}
if path == "" {
return fmt.Errorf("usage: homelab vault kv list <path>")
}
keys, err := kvList(realRunner, path)
if err != nil {
return err
}
for _, k := range keys {
fmt.Println(k)
}
return nil
}
func vaultKVPut(args []string) error {
hardenProcess()
ensureVaultAddr()
var path, key string
for _, a := range args {
if strings.HasPrefix(a, "-") {
continue
}
switch {
case path == "":
path = a
case key == "":
key = a
}
}
if path == "" || key == "" {
return fmt.Errorf("usage: homelab vault kv put <path> <key> (value read from stdin)")
}
value, err := readSecretValue("Value for " + key + ": ")
if err != nil {
return err
}
if value == "" {
return fmt.Errorf("empty value; aborting (nothing written)")
}
if err := kvPut(realRunner, realRunnerStdin, path, key, value); err != nil {
return fmt.Errorf("writing %q to %s failed (does your token have write access? path correct?): %w", key, path, err)
}
fmt.Fprintln(os.Stderr, "wrote "+key+" to "+path)
return nil
}
// readSecretValue obtains a secret value WITHOUT putting it in argv: piped stdin
// is read verbatim (trailing newline trimmed, internal newlines preserved so
// multi-line values like PEM keys survive); an interactive TTY is prompted
// without echo.
func readSecretValue(prompt string) (string, error) {
fi, err := os.Stdin.Stat()
if err == nil && fi.Mode()&os.ModeCharDevice == 0 {
b, rerr := io.ReadAll(os.Stdin)
if rerr != nil {
return "", rerr
}
return strings.TrimRight(string(b), "\r\n"), nil
}
return promptNoEcho(prompt)
}

View file

@ -882,3 +882,176 @@ func TestReadSucceedsWhenSyncFails(t *testing.T) {
t.Fatalf("read must succeed despite a sync failure: val=%q err=%v", val, err)
}
}
// --- vault kv (HashiCorp Vault / OpenBao infra secrets) --------------------
func TestVaultKVCommandsRegistered(t *testing.T) {
want := map[string]Tier{
"vault kv get": TierRead,
"vault kv list": TierRead,
"vault kv put": 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", name, got[name], tier)
}
}
}
func TestVaultKVArgs(t *testing.T) {
if got := vaultKVGetFieldArgs("secret/viktor", "github_pat"); !reflect.DeepEqual(got, []string{"kv", "get", "-field=github_pat", "secret/viktor"}) {
t.Fatalf("vaultKVGetFieldArgs = %v", got)
}
if got := vaultKVGetJSONArgs("secret/viktor"); !reflect.DeepEqual(got, []string{"kv", "get", "-format=json", "secret/viktor"}) {
t.Fatalf("vaultKVGetJSONArgs = %v", got)
}
if got := vaultKVListArgs("secret/"); !reflect.DeepEqual(got, []string{"kv", "list", "-format=json", "secret/"}) {
t.Fatalf("vaultKVListArgs = %v", got)
}
// create (path absent) → put; merge (path present) → patch -method=rw. Either
// way the VALUE travels via the `key=-` stdin form, never argv.
create := vaultKVPutArgs(false, "secret/x", "api_key")
if !reflect.DeepEqual(create, []string{"kv", "put", "secret/x", "api_key=-"}) {
t.Fatalf("vaultKVPutArgs(create) = %v", create)
}
merge := vaultKVPutArgs(true, "secret/x", "api_key")
if !reflect.DeepEqual(merge, []string{"kv", "patch", "-method=rw", "secret/x", "api_key=-"}) {
t.Fatalf("vaultKVPutArgs(merge) = %v", merge)
}
for _, args := range [][]string{create, merge} {
for _, a := range args {
if strings.Contains(a, "SECRETVALUE") || strings.HasSuffix(a, "=SECRETVALUE") {
t.Fatalf("value must not appear in argv: %v", args)
}
}
}
}
func TestExtractKVData(t *testing.T) {
// `vault kv get -format=json` wraps the secret in {"data":{"data":{...},"metadata":{...}}}.
env := `{"request_id":"x","data":{"data":{"github_pat":"ghp_abc","email":"e@x.me"},"metadata":{"version":3}}}`
out, err := extractKVData(env)
if err != nil {
t.Fatalf("extractKVData: %v", err)
}
// Round-trip to a map so key order doesn't matter.
var m map[string]string
if err := json.Unmarshal([]byte(out), &m); err != nil {
t.Fatalf("result not a JSON object: %q (%v)", out, err)
}
if m["github_pat"] != "ghp_abc" || m["email"] != "e@x.me" {
t.Fatalf("extractKVData inner data wrong: %v", m)
}
// metadata must NOT leak into the output.
if strings.Contains(out, "metadata") || strings.Contains(out, "request_id") {
t.Fatalf("envelope internals leaked: %s", out)
}
if _, err := extractKVData("not json"); err == nil {
t.Fatal("malformed envelope must error")
}
}
func TestParseKVList(t *testing.T) {
keys, err := parseKVList(`["app1","app2/","viktor"]`)
if err != nil {
t.Fatalf("parseKVList: %v", err)
}
if !reflect.DeepEqual(keys, []string{"app1", "app2/", "viktor"}) {
t.Fatalf("parseKVList = %v", keys)
}
if _, err := parseKVList("not json"); err == nil {
t.Fatal("malformed list must error")
}
}
func TestKVGetFieldFlow(t *testing.T) {
f := &fakeRunner{out: map[string]string{
"vault kv get -field=github_pat secret/viktor": "ghp_secret",
}}
val, err := kvGetField(f.run, "secret/viktor", "github_pat")
if err != nil || val != "ghp_secret" {
t.Fatalf("kvGetField = %q, %v", val, err)
}
}
func TestKVListFlow(t *testing.T) {
f := &fakeRunner{out: map[string]string{
"vault kv list -format=json secret/": `["app1","app2/"]`,
}}
keys, err := kvList(f.run, "secret/")
if err != nil || !reflect.DeepEqual(keys, []string{"app1", "app2/"}) {
t.Fatalf("kvList = %v, %v", keys, err)
}
}
// kvPut creates the path on first write and merges thereafter, with the value on
// stdin only (mirrors writeCreds). Never plain `kv patch` (needs the patch cap).
func TestKVPutCreatesThenMerges(t *testing.T) {
for _, tc := range []struct {
name string
exists bool
wantCreate bool
}{
{"absent path → create (put)", false, true},
{"present path → merge (patch -rw)", true, false},
} {
t.Run(tc.name, func(t *testing.T) {
var stdinCalls []recStdin
run := func(name string, argv, envv []string) (string, error) {
if len(argv) >= 2 && argv[0] == "kv" && argv[1] == "get" {
if tc.exists {
return `{"data":{"data":{}}}`, nil
}
return "", fmt.Errorf("No value found at secret/x")
}
return "", nil
}
runStdin := func(name string, argv, envv []string, stdin string) (string, error) {
stdinCalls = append(stdinCalls, recStdin{append([]string{name}, argv...), stdin})
return "", nil
}
if err := kvPut(run, runStdin, "secret/x", "api_key", "SECRETVALUE"); err != nil {
t.Fatalf("kvPut: %v", err)
}
if len(stdinCalls) != 1 {
t.Fatalf("want exactly 1 stdin write, got %d", len(stdinCalls))
}
sc := stdinCalls[0]
joined := strings.Join(sc.argv, " ")
if tc.wantCreate && !strings.Contains(joined, "kv put") {
t.Fatalf("absent path must use `kv put`: %v", sc.argv)
}
if !tc.wantCreate && !strings.Contains(joined, "kv patch -method=rw") {
t.Fatalf("present path must merge via `kv patch -method=rw`: %v", sc.argv)
}
if strings.Contains(joined, "kv patch") && !strings.Contains(joined, "-method=rw") {
t.Fatalf("must never use plain `kv patch`: %v", sc.argv)
}
if sc.stdin != "SECRETVALUE" {
t.Fatalf("value must travel via stdin, got %q", sc.stdin)
}
for _, a := range sc.argv {
if strings.Contains(a, "SECRETVALUE") {
t.Fatalf("value leaked into argv: %v", sc.argv)
}
}
})
}
}
func TestVaultHelpMentionsBothSystems(t *testing.T) {
h := vaultHelp()
for _, want := range []string{"Vaultwarden", "vault kv"} {
if !strings.Contains(h, want) {
t.Errorf("vault help must mention %q (distinguish the two systems)", want)
}
}
// Must name the infra-secrets system so the distinction is unambiguous.
if !strings.Contains(h, "HashiCorp") && !strings.Contains(h, "OpenBao") {
t.Error("vault help must name HashiCorp Vault / OpenBao (the infra secrets store)")
}
}