diff --git a/README.md b/README.md index e3a806f..b6a015a 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,10 @@ names may contain slashes, which nest on disk in the token cache. See rules. Tokens are cached under `~/.cache/vcm/` (`$XDG_CACHE_HOME` honoured) -as JSON with restrictive permissions (files `0600`, dirs `0700`), storing the -token plus its accessor, policies, TTL/expiry and renewable flag. +with restrictive permissions (files `0600`, dirs `0700`), storing the token +plus its accessor, policies, TTL/expiry and renewable flag. Files are +encrypted with a per-boot key in the kernel user keyring; after a reboot the +tokens show as locked, so run `vcm login` again. ## Layout diff --git a/docs/vcm.md b/docs/vcm.md index c392275..322a76f 100644 --- a/docs/vcm.md +++ b/docs/vcm.md @@ -101,11 +101,16 @@ the auth path follows the new method. ## Token cache Tokens are written to `~/.cache/vcm/` (honouring `$XDG_CACHE_HOME`) -as JSON. Slash contexts nest: `staging/sydney` → +encrypted (AES-256-GCM) with a random key held in the kernel user keyring +(description `vcm`, shared with `vctx`). The key lives only until reboot, so +after a reboot cached tokens can no longer be decrypted: `vcm list` shows +them as `locked — run 'vcm login '` and `vctx` errors with the same +hint. Files written by older, plaintext versions show as locked too; logging +in again replaces them. Slash contexts nest: `staging/sydney` → `~/.cache/vcm/staging/sydney`. Parent directories are created `0700` and token files are written `0600`. -Each file stores enough to inspect and later revoke the token: +The decrypted payload stores enough to inspect and later revoke the token: ```json { diff --git a/docs/vctx.md b/docs/vctx.md index 2d35415..36ca1e8 100644 --- a/docs/vctx.md +++ b/docs/vctx.md @@ -20,7 +20,7 @@ vctx --context sydney kv put kv/foo/bar secret=baz 1. Resolves the `sydney` context from `~/.config/vcm/config.yaml` — the exact file `vcm` uses. 2. Loads the cached token from `~/.cache/vcm/sydney` (errors with a hint to - run `vcm login sydney` if none exists). + run `vcm login sydney` if none exists or it is locked after a reboot). 3. Sets, for this process only: - `VAULT_ADDR` = the context's address - `VAULT_TOKEN` = the cached token diff --git a/go.mod b/go.mod index 4e91f6d..da5392c 100644 --- a/go.mod +++ b/go.mod @@ -1,15 +1,16 @@ module git.unkin.net/unkin/vault-tools -go 1.25 +go 1.25.0 require ( + git.unkin.net/unkin/golib v0.2.0 github.com/spf13/cobra v1.10.2 - golang.org/x/term v0.30.0 + golang.org/x/sys v0.47.0 + golang.org/x/term v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect - golang.org/x/sys v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 09ec722..5d32f83 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +git.unkin.net/unkin/golib v0.2.0 h1:PrgjEJNhavODOKXdf2WCF2z0eVT+NsB4EoVlX7TOvwE= +git.unkin.net/unkin/golib v0.2.0/go.mod h1:3lVQcPSwJm8ExD64U5edmVmdo9gPICkKJ4pbrT4bulo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= @@ -7,10 +9,10 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= -golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= -golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/shared/cache.go b/shared/cache.go index 948b766..df4b49e 100644 --- a/shared/cache.go +++ b/shared/cache.go @@ -2,13 +2,24 @@ package shared import ( "encoding/json" + "errors" "fmt" "os" "path/filepath" "strings" "time" + + "git.unkin.net/unkin/golib/keyring" ) +// KeyringDesc names the kernel keyring key that seals cached tokens; vcm and +// vctx share it. Tests override it. +var KeyringDesc = "vcm" + +// ErrLocked means a cached token exists but cannot be decrypted: the per-boot +// keyring key is gone (reboot) or the file predates encryption. +var ErrLocked = errors.New("token locked") + // Token is the cached result of a successful login. It stores enough detail to // later inspect or revoke the token (accessor, policies) and to reason about // its lifetime (issued/expiry, renewable) — not just the secret itself. @@ -82,8 +93,9 @@ func validateContextName(context string) error { return nil } -// SaveToken writes a token to its cache path as JSON, creating parent -// directories (0700) as needed and writing the file with 0600 permissions. +// SaveToken writes a token to its cache path as keyring-sealed JSON, creating +// parent directories (0700) as needed and writing the file with 0600 +// permissions. func SaveToken(t *Token) error { path, err := TokenPath(t.Context) if err != nil { @@ -96,24 +108,38 @@ func SaveToken(t *Token) error { if err != nil { return fmt.Errorf("encoding token: %w", err) } - data = append(data, '\n') + k, err := keyring.Open(KeyringDesc) + if err != nil { + return err + } + sealed, err := k.Seal(data) + if err != nil { + return fmt.Errorf("encrypting token: %w", err) + } - // Write via a temp file + rename so a token file is never left partially - // written, and create it 0600 from the start (never briefly world-readable). - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0o600); err != nil { + // Write via a unique temp file + rename so a token file is never left + // partially written and concurrent writers never share a temp file. + f, err := os.CreateTemp(filepath.Dir(path), ".tmp-*") + if err != nil { return fmt.Errorf("writing token: %w", err) } - if err := os.Rename(tmp, path); err != nil { - _ = os.Remove(tmp) + defer func() { _ = os.Remove(f.Name()) }() + _, err = f.Write(sealed) + if cerr := f.Close(); err == nil { + err = cerr + } + if err != nil { + return fmt.Errorf("writing token: %w", err) + } + if err := os.Rename(f.Name(), path); err != nil { return fmt.Errorf("saving token: %w", err) } return nil } -// LoadToken reads and decodes a cached token for a context. A missing token -// returns an error wrapping os.ErrNotExist so callers can detect "not logged -// in" with errors.Is. +// LoadToken reads and decrypts a cached token for a context. A missing token +// wraps os.ErrNotExist; one that cannot be decrypted (key lost on reboot, or a +// legacy plaintext file) wraps ErrLocked. Both mean "run vcm login". func LoadToken(context string) (*Token, error) { path, err := TokenPath(context) if err != nil { @@ -126,13 +152,31 @@ func LoadToken(context string) (*Token, error) { } return nil, fmt.Errorf("reading token %s: %w", path, err) } + if json.Valid(data) { + return nil, lockedErr(context) + } + k, err := keyring.Open(KeyringDesc) + if err != nil { + return nil, err + } + plain, err := k.Unseal(data) + if errors.Is(err, keyring.ErrKeyUnavailable) { + return nil, lockedErr(context) + } + if err != nil { + return nil, fmt.Errorf("decrypting token %s: %w", path, err) + } var t Token - if err := json.Unmarshal(data, &t); err != nil { + if err := json.Unmarshal(plain, &t); err != nil { return nil, fmt.Errorf("parsing token %s: %w", path, err) } return &t, nil } +func lockedErr(context string) error { + return fmt.Errorf("cached token for context %q is locked (run 'vcm login %s'): %w", context, context, ErrLocked) +} + // DeleteToken removes a context's cached token file, if present. func DeleteToken(context string) error { path, err := TokenPath(context) diff --git a/shared/keyring_test.go b/shared/keyring_test.go new file mode 100644 index 0000000..b569e10 --- /dev/null +++ b/shared/keyring_test.go @@ -0,0 +1,132 @@ +package shared + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "git.unkin.net/unkin/golib/keyring" + "golang.org/x/sys/unix" +) + +// TestMain seals tokens with a run-unique key, skipping outside CI where +// keyctl is unimplemented. +func TestMain(m *testing.M) { + KeyringDesc = fmt.Sprintf("vault-tools-test:shared:%d", os.Getpid()) + k, err := keyring.Open(KeyringDesc) + if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" { + fmt.Println("skipping: kernel keyring unavailable:", err) + os.Exit(0) + } + if err != nil { + fmt.Println(err) + os.Exit(1) + } + code := m.Run() + _ = k.Forget() + os.Exit(code) +} + +func writeRaw(t *testing.T, context string, data []byte) string { + t.Helper() + path, err := TokenPath(context) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func TestSaveTokenSealsAtRest(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + if err := SaveToken(&Token{Context: "sydney", Token: "hvs.secret"}); err != nil { + t.Fatal(err) + } + path, _ := TokenPath("sydney") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(data, []byte("hvs.secret")) { + t.Fatal("token stored in plaintext") + } + entries, _ := os.ReadDir(filepath.Dir(path)) + if len(entries) != 1 { + t.Errorf("cache dir has %d entries, want only the token file", len(entries)) + } +} + +func TestLoadTokenLockedAfterKeyLoss(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + if err := SaveToken(&Token{Context: "sydney", Token: "s.x"}); err != nil { + t.Fatal(err) + } + k, err := keyring.Open(KeyringDesc) + if err != nil { + t.Fatal(err) + } + if err := k.Forget(); err != nil { + t.Fatal(err) + } + _, err = LoadToken("sydney") + if !errors.Is(err, ErrLocked) || !strings.Contains(err.Error(), "vcm login sydney") { + t.Fatalf("LoadToken = %v, want ErrLocked with login hint", err) + } + if path, _ := TokenPath("sydney"); !fileExists(path) { + t.Error("locked token file was deleted") + } +} + +func TestLoadTokenLegacyPlaintextIsLocked(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + if err := os.MkdirAll(CacheDir(), 0o700); err != nil { + t.Fatal(err) + } + path := writeRaw(t, "sydney", []byte(`{"context":"sydney","token":"s.x"}`+"\n")) + if _, err := LoadToken("sydney"); !errors.Is(err, ErrLocked) { + t.Fatalf("LoadToken = %v, want ErrLocked", err) + } + if !fileExists(path) { + t.Error("legacy token file was deleted") + } +} + +func TestLoadTokenCorruptNamesFile(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + if err := SaveToken(&Token{Context: "sydney", Token: "s.x"}); err != nil { + t.Fatal(err) + } + path, _ := TokenPath("sydney") + data, _ := os.ReadFile(path) + data[len(data)-1] ^= 0xff + writeRaw(t, "sydney", data) + _, err := LoadToken("sydney") + if !errors.Is(err, keyring.ErrCorrupt) || !strings.Contains(err.Error(), path) { + t.Fatalf("LoadToken = %v, want ErrCorrupt naming %s", err, path) + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func TestSaveTokenRenameFailureLeavesNoTemp(t *testing.T) { + t.Setenv("XDG_CACHE_HOME", t.TempDir()) + path, _ := TokenPath("sydney") + if err := os.MkdirAll(filepath.Join(path, "blocker"), 0o700); err != nil { + t.Fatal(err) + } + if err := SaveToken(&Token{Context: "sydney", Token: "s.x"}); err == nil { + t.Fatal("expected rename onto a non-empty directory to fail") + } + if entries, _ := os.ReadDir(CacheDir()); len(entries) != 1 { + t.Errorf("cache dir has %d entries, want temp file cleaned up", len(entries)) + } +} diff --git a/vcm/keyring_test.go b/vcm/keyring_test.go new file mode 100644 index 0000000..3126a23 --- /dev/null +++ b/vcm/keyring_test.go @@ -0,0 +1,58 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "git.unkin.net/unkin/golib/keyring" + "git.unkin.net/unkin/vault-tools/shared" + "golang.org/x/sys/unix" +) + +// TestMain seals tokens with a run-unique key, skipping outside CI where +// keyctl is unimplemented. +func TestMain(m *testing.M) { + shared.KeyringDesc = fmt.Sprintf("vault-tools-test:vcm:%d", os.Getpid()) + k, err := keyring.Open(shared.KeyringDesc) + if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" { + fmt.Println("skipping: kernel keyring unavailable:", err) + os.Exit(0) + } + if err != nil { + fmt.Println(err) + os.Exit(1) + } + code := m.Run() + _ = k.Forget() + os.Exit(code) +} + +func TestRunListLockedAndUnreadable(t *testing.T) { + setupVcm(t, vcmConfig) + dir := filepath.Join(shared.CacheDir(), "staging") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(shared.CacheDir(), "sydney"), []byte(`{"token":"s.x"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "sydney"), []byte("\x01garbage"), 0o600); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := runList(&buf); err != nil { + t.Fatal(err) + } + out := buf.String() + if !strings.Contains(out, "locked — run 'vcm login sydney'") { + t.Errorf("expected locked status for sydney:\n%s", out) + } + if !strings.Contains(out, "sealed data corrupt") { + t.Errorf("expected corrupt status for staging/sydney:\n%s", out) + } +} diff --git a/vcm/main.go b/vcm/main.go index 3ac7ec7..ec0c7de 100644 --- a/vcm/main.go +++ b/vcm/main.go @@ -11,6 +11,7 @@ package main import ( + "errors" "fmt" "io" "os" @@ -252,7 +253,14 @@ func runList(w io.Writer) error { continue } status := "no token" - if tok, err := shared.LoadToken(name); err == nil { + tok, err := shared.LoadToken(name) + switch { + case errors.Is(err, os.ErrNotExist): + case errors.Is(err, shared.ErrLocked): + status = fmt.Sprintf("locked — run 'vcm login %s'", name) + case err != nil: + status = err.Error() + default: if !tok.ExpiresAt.IsZero() { remaining := time.Until(tok.ExpiresAt) if remaining > 0 { diff --git a/vctx/keyring_test.go b/vctx/keyring_test.go new file mode 100644 index 0000000..00c12ac --- /dev/null +++ b/vctx/keyring_test.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "fmt" + "os" + "strings" + "testing" + + "git.unkin.net/unkin/golib/keyring" + "git.unkin.net/unkin/vault-tools/shared" + "golang.org/x/sys/unix" +) + +// TestMain seals tokens with a run-unique key, skipping outside CI where +// keyctl is unimplemented. +func TestMain(m *testing.M) { + shared.KeyringDesc = fmt.Sprintf("vault-tools-test:vctx:%d", os.Getpid()) + k, err := keyring.Open(shared.KeyringDesc) + if errors.Is(err, unix.ENOSYS) && os.Getenv("CI") == "" { + fmt.Println("skipping: kernel keyring unavailable:", err) + os.Exit(0) + } + if err != nil { + fmt.Println(err) + os.Exit(1) + } + code := m.Run() + _ = k.Forget() + os.Exit(code) +} + +func TestBuildInvocationLockedToken(t *testing.T) { + setupVctx(t, vctxConfig, map[string]shared.Token{"sydney": {Address: "https://v", Token: "s.X"}}) + k, err := keyring.Open(shared.KeyringDesc) + if err != nil { + t.Fatal(err) + } + if err := k.Forget(); err != nil { + t.Fatal(err) + } + _, _, _, err = buildInvocation("sydney", "", []string{"status"}, nil) + if !errors.Is(err, shared.ErrLocked) || !strings.Contains(err.Error(), "vcm login sydney") { + t.Fatalf("buildInvocation = %v, want ErrLocked with login hint", err) + } +}