package auth import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "fmt" "os" "path/filepath" "strings" "sync" "syscall" "github.com/pelletier/go-toml/v2" ) const NameMetrics = "metrics" type Caller struct { Name string `toml:"name"` Workspace string `toml:"workspace"` KeySHA256 string `toml:"key_sha256"` } // MetricsOnly is the scrape-token caller: empty workspace, no fetch/search. func (c *Caller) MetricsOnly() bool { return c != nil && c.Name == NameMetrics } type file struct { Caller []Caller `toml:"caller"` } // Store holds hashed keys. Reload on SIGHUP via Load. type Store struct { mu sync.RWMutex path string callers []Caller } func Load(path string) (*Store, error) { s := &Store{path: path} if err := s.reload(); err != nil { return nil, err } return s, nil } func (s *Store) Reload() error { return s.reload() } // Callers returns a copy of the loaded caller list (names and workspaces only). func (s *Store) Callers() []Caller { if s == nil { return nil } s.mu.RLock() defer s.mu.RUnlock() out := make([]Caller, len(s.callers)) copy(out, s.callers) return out } func (s *Store) reload() error { b, err := os.ReadFile(s.path) if err != nil { return err } var f file if err := toml.Unmarshal(b, &f); err != nil { return err } s.mu.Lock() s.callers = f.Caller s.mu.Unlock() return nil } // LookupBearer returns the caller for a raw bearer token, or nil. func (s *Store) LookupBearer(token string) *Caller { if token == "" || s == nil { return nil } sum := sha256.Sum256([]byte(token)) want := hex.EncodeToString(sum[:]) s.mu.RLock() defer s.mu.RUnlock() var found *Caller for i := range s.callers { got := strings.ToLower(strings.TrimSpace(s.callers[i].KeySHA256)) if len(got) != len(want) { continue } if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 { c := s.callers[i] found = &c } } return found } func HashKey(key string) string { sum := sha256.Sum256([]byte(key)) return hex.EncodeToString(sum[:]) } func GenerateKey() (string, error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(b[:]), nil } func Add(path, name, workspace, keyOut string) error { if name == NameMetrics { workspace = "" } if _, err := os.Stat(keyOut); err == nil { return fmt.Errorf("refusing to overwrite %s", keyOut) } key, err := GenerateKey() if err != nil { return err } if err := os.WriteFile(keyOut, []byte(key+"\n"), 0o600); err != nil { return err } var f file if b, err := os.ReadFile(path); err == nil { _ = toml.Unmarshal(b, &f) } f.Caller = append(f.Caller, Caller{Name: name, Workspace: workspace, KeySHA256: HashKey(key)}) out, err := toml.Marshal(f) if err != nil { return err } return writePreserving(path, out) } func List(path string) ([]Caller, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } var f file if err := toml.Unmarshal(b, &f); err != nil { return nil, err } return f.Caller, nil } func Revoke(path, name string) error { b, err := os.ReadFile(path) if err != nil { return err } var f file if err := toml.Unmarshal(b, &f); err != nil { return err } out := f.Caller[:0] for _, c := range f.Caller { if c.Name != name { out = append(out, c) } } f.Caller = out raw, err := toml.Marshal(f) if err != nil { return err } return writePreserving(path, raw) } func writePreserving(path string, data []byte) error { mode := os.FileMode(0o640) uid, gid := -1, -1 if st, err := os.Stat(path); err == nil { mode = st.Mode() if sys, ok := st.Sys().(*syscall.Stat_t); ok { uid, gid = int(sys.Uid), int(sys.Gid) } } f, err := os.CreateTemp(filepath.Dir(path), ".sift-keys-*.tmp") if err != nil { return err } tmp := f.Name() ok := false defer func() { if !ok { _ = os.Remove(tmp) } }() if _, err := f.Write(data); err != nil { _ = f.Close() return err } if err := f.Chmod(mode.Perm()); err != nil { _ = f.Close() return err } if uid >= 0 { if err := f.Chown(uid, gid); err != nil { _ = f.Close() return err } } if err := f.Close(); err != nil { return err } if err := os.Rename(tmp, path); err != nil { return err } ok = true return nil }