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 }