302 lines
9.6 KiB
Go
302 lines
9.6 KiB
Go
// Package keyring is sherlock's gateway to the OS credential store.
|
|
//
|
|
// Decision #8: persist tokens in the OS keyring (Secret Service on
|
|
// Linux, Keychain on macOS, Credential Manager on Windows). No
|
|
// plaintext or age-encrypted blobs on disk.
|
|
//
|
|
// Sherlock holds a wallet of OAuth sessions — one TokenSet per
|
|
// downstream service the operator has authenticated to (gitea,
|
|
// grafana, miniflux, …). There is no "master" Authentik session;
|
|
// every entry is service-keyed. MCPs that need a token call
|
|
// internal/authn.Ensure with their service name, which goes through
|
|
// this package.
|
|
//
|
|
// Open is the only public way to obtain a Store. It probes the OS
|
|
// keyring and fails fast with platform-specific guidance if the
|
|
// keyring is missing or locked. There is no escape hatch that skips
|
|
// the probe — without the keyring, sherlock would silently lose
|
|
// refresh tokens on every command.
|
|
package keyring
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"runtime"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
gokeyring "github.com/zalando/go-keyring"
|
|
)
|
|
|
|
// Service is the keyring "service" namespace under which sherlock
|
|
// stores secrets. Per-service token sets are stored under per-name
|
|
// account labels; the index of registered names lives under
|
|
// indexAccount so Store.List can enumerate without OS-specific search.
|
|
const Service = "sherlock"
|
|
|
|
const (
|
|
probeService = "sherlock-preflight"
|
|
probeAccount = "probe"
|
|
probeValue = "ok"
|
|
|
|
// tokenAccountPrefix gives every per-service TokenSet a distinct
|
|
// keyring "account" without colliding with the index entry.
|
|
tokenAccountPrefix = "service:"
|
|
|
|
// indexAccount holds a JSON array of service names with stored
|
|
// tokens, so Store.List() works on every backend without depending
|
|
// on libsecret-search / SecItemCopyMatching / etc.
|
|
indexAccount = "services-index"
|
|
)
|
|
|
|
// UnavailableError is returned by Open when the OS keyring is missing
|
|
// or non-responsive. Hint is platform-specific remediation text,
|
|
// populated by the constructor — callers don't need to look it up
|
|
// themselves, just read e.Hint or print e.Error() (which includes it).
|
|
type UnavailableError struct {
|
|
Cause error
|
|
Hint string
|
|
}
|
|
|
|
func (e *UnavailableError) Error() string {
|
|
if e.Hint == "" {
|
|
return fmt.Sprintf("os keyring unavailable: %v", e.Cause)
|
|
}
|
|
return fmt.Sprintf("os keyring unavailable: %v\n hint: %s", e.Cause, e.Hint)
|
|
}
|
|
|
|
func (e *UnavailableError) Unwrap() error { return e.Cause }
|
|
|
|
// IsUnavailable reports whether err wraps an *UnavailableError.
|
|
func IsUnavailable(err error) bool {
|
|
var u *UnavailableError
|
|
return errors.As(err, &u)
|
|
}
|
|
|
|
func newUnavailable(cause error) *UnavailableError {
|
|
return &UnavailableError{Cause: cause, Hint: hintForGOOS()}
|
|
}
|
|
|
|
func hintForGOOS() string {
|
|
switch runtime.GOOS {
|
|
case "linux":
|
|
return "install/start a Secret Service provider (gnome-keyring, KWallet, or keepassxc with Secret Service enabled). See https://specifications.freedesktop.org/secret-service/latest/."
|
|
case "darwin":
|
|
return "the system Keychain must be unlocked. Check `security list-keychains`."
|
|
case "windows":
|
|
return "the Credential Manager service must be running."
|
|
default:
|
|
return "no Secret Service implementation is wired in sherlock for " + runtime.GOOS
|
|
}
|
|
}
|
|
|
|
// Open is the only way to obtain a Store. It probes the OS keyring
|
|
// (write+read+delete of a sentinel value) and returns the live Store
|
|
// on success, or *UnavailableError on any failure.
|
|
//
|
|
// Safe to call repeatedly. The returned Store carries no per-instance
|
|
// state.
|
|
func Open() (Store, error) {
|
|
if err := probe(); err != nil {
|
|
return nil, err
|
|
}
|
|
return realStore{}, nil
|
|
}
|
|
|
|
func probe() error {
|
|
// Per-PID probe account so concurrent sherlock-spawned MCPs
|
|
// (gitea-mcp + gssh-mcp + …) don't race on a shared sentinel:
|
|
// without this, two probes can Set/Get/Delete the same key
|
|
// in lockstep, and the second Delete returns "secret not found",
|
|
// failing Open() on the loser.
|
|
account := probeAccount + ":" + strconv.Itoa(os.Getpid())
|
|
if err := gokeyring.Set(probeService, account, probeValue); err != nil {
|
|
return newUnavailable(fmt.Errorf("set: %w", err))
|
|
}
|
|
got, err := gokeyring.Get(probeService, account)
|
|
if err != nil {
|
|
_ = gokeyring.Delete(probeService, account)
|
|
return newUnavailable(fmt.Errorf("get: %w", err))
|
|
}
|
|
if got != probeValue {
|
|
_ = gokeyring.Delete(probeService, account)
|
|
return newUnavailable(fmt.Errorf("readback mismatch: %q", got))
|
|
}
|
|
if err := gokeyring.Delete(probeService, account); err != nil {
|
|
return newUnavailable(fmt.Errorf("delete: %w", err))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// TokenSet is what sherlock persists per service after a successful
|
|
// OAuth flow. ClientID + ClientSecret + Scopes + Issuer are persisted
|
|
// alongside the tokens so that background refresh works without
|
|
// re-reading any external configuration.
|
|
type TokenSet struct {
|
|
IDToken string `json:"id_token"`
|
|
AccessToken string `json:"access_token,omitempty"`
|
|
RefreshToken string `json:"refresh_token,omitempty"`
|
|
AccessExpAt time.Time `json:"access_expires_at,omitempty"`
|
|
IDExpiresAt time.Time `json:"id_expires_at"`
|
|
RefreshExpAt time.Time `json:"refresh_expires_at"`
|
|
Issuer string `json:"issuer"`
|
|
ClientID string `json:"client_id,omitempty"`
|
|
ClientSecret string `json:"client_secret,omitempty"`
|
|
Scopes []string `json:"scopes,omitempty"`
|
|
Subject string `json:"sub"`
|
|
Email string `json:"email,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
}
|
|
|
|
// Empty reports whether the TokenSet carries no usable session.
|
|
func (t TokenSet) Empty() bool { return t.IDToken == "" && t.RefreshToken == "" }
|
|
|
|
// Store is the typed wallet sherlock uses. Implementations must be
|
|
// safe for concurrent use (the OS keyring backend serialises writes
|
|
// internally; the in-memory fake guards with a mutex).
|
|
//
|
|
// The service argument is a short name like "gitea" or "grafana".
|
|
// Callers must not embed colons or other separators — the package
|
|
// scopes everything under tokenAccountPrefix internally.
|
|
type Store interface {
|
|
// Get returns the TokenSet for service, or ErrNoTokens if no entry
|
|
// exists.
|
|
Get(service string) (TokenSet, error)
|
|
// Set persists ts under service, atomically updating the
|
|
// registered-services index.
|
|
Set(service string, ts TokenSet) error
|
|
// Clear removes the entry for service. Returns nil if nothing was
|
|
// stored.
|
|
Clear(service string) error
|
|
// List returns the names of every service with stored tokens.
|
|
List() ([]string, error)
|
|
}
|
|
|
|
// ErrNoTokens is returned by Get when nothing is stored for a service.
|
|
var ErrNoTokens = errors.New("keyring: no tokens stored")
|
|
|
|
// realStore is the production Store backed by zalando/go-keyring.
|
|
// Unexported on purpose — callers go through Open.
|
|
type realStore struct{}
|
|
|
|
func (realStore) Get(service string) (TokenSet, error) {
|
|
if err := validateName(service); err != nil {
|
|
return TokenSet{}, err
|
|
}
|
|
raw, err := gokeyring.Get(Service, tokenAccountPrefix+service)
|
|
if err != nil {
|
|
if errors.Is(err, gokeyring.ErrNotFound) {
|
|
return TokenSet{}, ErrNoTokens
|
|
}
|
|
return TokenSet{}, fmt.Errorf("keyring: get %s: %w", service, err)
|
|
}
|
|
var ts TokenSet
|
|
if err := json.Unmarshal([]byte(raw), &ts); err != nil {
|
|
return TokenSet{}, fmt.Errorf("keyring: decode %s: %w", service, err)
|
|
}
|
|
return ts, nil
|
|
}
|
|
|
|
func (s realStore) Set(service string, ts TokenSet) error {
|
|
if err := validateName(service); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.Marshal(ts)
|
|
if err != nil {
|
|
return fmt.Errorf("keyring: encode %s: %w", service, err)
|
|
}
|
|
if err := gokeyring.Set(Service, tokenAccountPrefix+service, string(b)); err != nil {
|
|
return fmt.Errorf("keyring: set %s: %w", service, err)
|
|
}
|
|
return s.addToIndex(service)
|
|
}
|
|
|
|
func (s realStore) Clear(service string) error {
|
|
if err := validateName(service); err != nil {
|
|
return err
|
|
}
|
|
if err := gokeyring.Delete(Service, tokenAccountPrefix+service); err != nil {
|
|
if !errors.Is(err, gokeyring.ErrNotFound) {
|
|
return fmt.Errorf("keyring: delete %s: %w", service, err)
|
|
}
|
|
}
|
|
return s.removeFromIndex(service)
|
|
}
|
|
|
|
func (realStore) List() ([]string, error) {
|
|
return readIndex()
|
|
}
|
|
|
|
func (s realStore) addToIndex(service string) error {
|
|
names, err := readIndex()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if slices.Contains(names, service) {
|
|
return nil
|
|
}
|
|
names = append(names, service)
|
|
slices.Sort(names)
|
|
return writeIndex(names)
|
|
}
|
|
|
|
func (s realStore) removeFromIndex(service string) error {
|
|
names, err := readIndex()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idx := slices.Index(names, service)
|
|
if idx < 0 {
|
|
return nil
|
|
}
|
|
names = slices.Delete(names, idx, idx+1)
|
|
if len(names) == 0 {
|
|
if err := gokeyring.Delete(Service, indexAccount); err != nil && !errors.Is(err, gokeyring.ErrNotFound) {
|
|
return fmt.Errorf("keyring: delete index: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
return writeIndex(names)
|
|
}
|
|
|
|
func readIndex() ([]string, error) {
|
|
raw, err := gokeyring.Get(Service, indexAccount)
|
|
if err != nil {
|
|
if errors.Is(err, gokeyring.ErrNotFound) {
|
|
return nil, nil
|
|
}
|
|
return nil, fmt.Errorf("keyring: get index: %w", err)
|
|
}
|
|
var names []string
|
|
if err := json.Unmarshal([]byte(raw), &names); err != nil {
|
|
return nil, fmt.Errorf("keyring: decode index: %w", err)
|
|
}
|
|
return names, nil
|
|
}
|
|
|
|
func writeIndex(names []string) error {
|
|
b, err := json.Marshal(names)
|
|
if err != nil {
|
|
return fmt.Errorf("keyring: encode index: %w", err)
|
|
}
|
|
if err := gokeyring.Set(Service, indexAccount, string(b)); err != nil {
|
|
return fmt.Errorf("keyring: set index: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateName(service string) error {
|
|
if service == "" {
|
|
return errors.New("keyring: empty service name")
|
|
}
|
|
for _, r := range service {
|
|
if r == ':' || r == '/' || r == ' ' {
|
|
return fmt.Errorf("keyring: invalid character %q in service name %q", r, service)
|
|
}
|
|
}
|
|
return nil
|
|
}
|