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:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user