Files
sherlock/internal/keyring/keyring.go
T
amacocian 59eaf9e47d
Release / release (push) Failing after 6s
SearXNG support
2026-06-14 21:33:22 +02:00

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
}