Merge pull request 'Encrypt cached tokens at rest' (#7) from benvin/encrypt-cache into main

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-10-23 23:19:45 +11:00
10 changed files with 324 additions and 26 deletions
+4 -2
View File
@@ -54,8 +54,10 @@ names may contain slashes, which nest on disk in the token cache. See
rules.
Tokens are cached under `~/.cache/vcm/<context>` (`$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
+7 -2
View File
@@ -101,11 +101,16 @@ the auth path follows the new method.
## Token cache
Tokens are written to `~/.cache/vcm/<context>` (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 <context>'` 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
{
+1 -1
View File
@@ -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
+4 -3
View File
@@ -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
)
+6 -4
View File
@@ -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=
+57 -13
View File
@@ -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)
+132
View File
@@ -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))
}
}
+58
View File
@@ -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)
}
}
+9 -1
View File
@@ -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 {
+46
View File
@@ -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)
}
}