Merge remote-tracking branch 'origin/master' into wizard/paperless-emo
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
041aedc486
4 changed files with 414 additions and 42 deletions
128
cli/cmd_vault.go
128
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/<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.
|
||||
// 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-<user>, 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-<user>. 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 <name>")
|
||||
}
|
||||
|
|
@ -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-<user> 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/<pid>/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/<pid>/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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
121
docs/runbooks/homelab-vault-onboarding.md
Normal file
121
docs/runbooks/homelab-vault-onboarding.md
Normal file
|
|
@ -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/<os-user>` 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 <name> [--field password|username|uri|notes|totp] [--json]
|
||||
homelab vault code <name> 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-<user>`).
|
||||
|
||||
That scoped policy grants exactly `create`/`read`/`update` on the user's own
|
||||
`secret/workstation/claude-users/<user>` 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-<user>` 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/<user>/.config/claude-auth-sync/vault-token)" \
|
||||
vault token lookup -format=json | jq '.data.display_name, .data.policies'
|
||||
# → "token-devvm-claude-auth-<user>", [..., "workstation-claude-<user>"]
|
||||
|
||||
sudo -u <user> -i bw --version # /usr/bin/bw resolves for the user
|
||||
sudo -u <user> -i homelab vault status
|
||||
```
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue