191 lines
6.0 KiB
Go
191 lines
6.0 KiB
Go
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.
|
|
type Token struct {
|
|
// Context is the name of the context this token belongs to.
|
|
Context string `json:"context"`
|
|
// Address is the Vault address the token is valid against.
|
|
Address string `json:"address"`
|
|
// Namespace is the Vault namespace the token was issued in (may be empty).
|
|
Namespace string `json:"namespace,omitempty"`
|
|
|
|
// Token is the client token secret.
|
|
Token string `json:"token"`
|
|
// Accessor identifies the token without exposing it — enough to revoke it
|
|
// via /auth/token/revoke-accessor.
|
|
Accessor string `json:"accessor,omitempty"`
|
|
// Policies are the policies attached to the token.
|
|
Policies []string `json:"policies,omitempty"`
|
|
|
|
// Renewable reports whether the token can be renewed.
|
|
Renewable bool `json:"renewable"`
|
|
// LeaseDurationSeconds is the TTL granted at issue/renew time.
|
|
LeaseDurationSeconds int `json:"lease_duration_seconds,omitempty"`
|
|
|
|
// IssuedAt is when this token was obtained (login) or last renewed.
|
|
IssuedAt time.Time `json:"issued_at"`
|
|
// ExpiresAt is IssuedAt + LeaseDuration; zero for non-expiring tokens.
|
|
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
|
}
|
|
|
|
// CacheDir returns the XDG_CACHE_HOME/vcm directory that holds token files.
|
|
func CacheDir() string {
|
|
base := os.Getenv("XDG_CACHE_HOME")
|
|
if base == "" {
|
|
home, _ := os.UserHomeDir()
|
|
base = filepath.Join(home, ".cache")
|
|
}
|
|
return filepath.Join(base, appDir)
|
|
}
|
|
|
|
// TokenPath returns the on-disk path for a context's cached token. A context
|
|
// name with slashes (e.g. "staging/sydney") maps to a nested path under
|
|
// CacheDir. The name is validated to prevent escaping the cache directory.
|
|
func TokenPath(context string) (string, error) {
|
|
if err := validateContextName(context); err != nil {
|
|
return "", err
|
|
}
|
|
// Use forward slashes as path separators regardless of platform, matching
|
|
// how contexts are written in the config file.
|
|
rel := filepath.FromSlash(context)
|
|
return filepath.Join(CacheDir(), rel), nil
|
|
}
|
|
|
|
// validateContextName rejects names that could escape the cache directory or
|
|
// are otherwise unusable as a relative path.
|
|
func validateContextName(context string) error {
|
|
if context == "" {
|
|
return fmt.Errorf("empty context name")
|
|
}
|
|
if strings.HasPrefix(context, "/") || filepath.IsAbs(context) {
|
|
return fmt.Errorf("context name %q must not be absolute", context)
|
|
}
|
|
for _, seg := range strings.Split(context, "/") {
|
|
if seg == "" {
|
|
return fmt.Errorf("context name %q has an empty path segment", context)
|
|
}
|
|
if seg == "." || seg == ".." {
|
|
return fmt.Errorf("context name %q must not contain %q segments", context, seg)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
|
return fmt.Errorf("creating cache dir: %w", err)
|
|
}
|
|
data, err := json.MarshalIndent(t, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("encoding token: %w", err)
|
|
}
|
|
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 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)
|
|
}
|
|
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 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 {
|
|
return nil, err
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, fmt.Errorf("no cached token for context %q (run 'vcm login %s'): %w", context, context, os.ErrNotExist)
|
|
}
|
|
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(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)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
|
return fmt.Errorf("removing token %s: %w", path, err)
|
|
}
|
|
return nil
|
|
}
|