package auth import ( "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "sync" "time" ) // APIKey is the stored record for a minted key. Only the SHA-256 of the // full key string is kept; the key itself is shown exactly once at mint // time. SHA-256 (not argon2/bcrypt) is sufficient because keys are 192-bit // random secrets, not low-entropy passwords. type APIKey struct { ID string `json:"id"` Name string `json:"name"` Hash string `json:"hash"` // hex sha256 of the full key string Scopes []string `json:"scopes,omitempty"` CreatedAt time.Time `json:"createdAt"` } // ErrKeyNotFound is returned by Revoke for an unknown key id. var ErrKeyNotFound = errors.New("auth: api key not found") // KeyStore is a file-backed set of API keys ( is JSON, written // atomically). Volume is tiny — a handful of integration keys — so a flat // file beats dragging key records into the event-sourced graph store. // // One store can be shared across services (e.g. /commerce/apikeys.json on a // common volume): the writer mints/revokes in-process, readers poll with // ReloadIfChanged so a newly minted key works without a restart. type KeyStore struct { path string mu sync.Mutex keys []APIKey modTime time.Time // file mtime seen at the last load } // OpenKeyStore loads (or initializes) the key store at path. func OpenKeyStore(path string) (*KeyStore, error) { s := &KeyStore{path: path} if err := s.ReloadIfChanged(); err != nil { return nil, err } return s, nil } // Mint creates a key with the given display name and scopes, persists its // hash, and returns the full key string — the only time it is available. func (s *KeyStore) Mint(name string, scopes []string) (key string, rec APIKey, err error) { idBytes := make([]byte, 4) secret := make([]byte, 24) if _, err = rand.Read(idBytes); err != nil { return "", APIKey{}, err } if _, err = rand.Read(secret); err != nil { return "", APIKey{}, err } id := hex.EncodeToString(idBytes) key = "gck_" + id + "_" + base64.RawURLEncoding.EncodeToString(secret) rec = APIKey{ ID: id, Name: name, Hash: hashKey(key), Scopes: scopes, CreatedAt: time.Now().UTC(), } s.mu.Lock() defer s.mu.Unlock() s.keys = append(s.keys, rec) if err := s.saveLocked(); err != nil { s.keys = s.keys[:len(s.keys)-1] return "", APIKey{}, err } s.modTime = time.Now() return key, rec, nil } // Revoke deletes the key with the given id. func (s *KeyStore) Revoke(id string) error { s.mu.Lock() defer s.mu.Unlock() for i, k := range s.keys { if k.ID == id { s.keys = append(s.keys[:i], s.keys[i+1:]...) err := s.saveLocked() s.modTime = time.Now() return err } } return ErrKeyNotFound } // List returns the stored key records. Callers serving them over HTTP // should strip Hash themselves — it isn't secret-bearing, but is also not // a client concern. func (s *KeyStore) List() []APIKey { s.mu.Lock() defer s.mu.Unlock() out := make([]APIKey, len(s.keys)) copy(out, s.keys) return out } // lookup finds the record whose hash matches the presented key. func (s *KeyStore) lookup(presented string) (APIKey, bool) { h := []byte(hashKey(presented)) s.mu.Lock() defer s.mu.Unlock() for _, k := range s.keys { if subtle.ConstantTimeCompare(h, []byte(k.Hash)) == 1 { return k, true } } return APIKey{}, false } // ReloadIfChanged re-reads the backing file when its mtime changed since the // last load (the store may have been updated by another process — the CMS // mints keys, services in other containers validate them). Cheap: one stat // when nothing changed. Safe to call on every request. func (s *KeyStore) ReloadIfChanged() error { info, err := os.Stat(s.path) if errors.Is(err, os.ErrNotExist) { return nil // no store yet — no keys, and nothing to reload } if err != nil { return err } s.mu.Lock() defer s.mu.Unlock() if !info.ModTime().After(s.modTime) && s.keys != nil { return nil // unchanged } raw, err := os.ReadFile(s.path) if err != nil { return err } var keys []APIKey if err := json.Unmarshal(raw, &keys); err != nil { return fmt.Errorf("auth: parse %s: %w", s.path, err) } s.keys = keys s.modTime = info.ModTime() return nil } // saveLocked writes the store atomically (tmp + rename). Callers hold s.mu. // The written file's mtime is intentionally left to the filesystem so other // processes can detect changes via ReloadIfChanged. func (s *KeyStore) saveLocked() error { raw, err := json.MarshalIndent(s.keys, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil { return err } tmp := s.path + ".tmp" // 0644, not 0600: the store holds only SHA-256 hashes + metadata (no // plaintext keys) and is read by OTHER services in their own containers // (they run as different, non-root uids), so the written file must be // world-readable. A 0600 file owned by the writing pod's uid would go // unreadable to them and freeze their ReloadIfChanged snapshots. if err := os.WriteFile(tmp, raw, 0o644); err != nil { return err } return os.Rename(tmp, s.path) } func hashKey(key string) string { sum := sha256.Sum256([]byte(key)) return hex.EncodeToString(sum[:]) } // APIKeyAuth authenticates requests carrying `X-API-Key: ` against a // KeyStore. The Principal's Subject is the key id and its Scopes are the // scopes the key was minted with. type APIKeyAuth struct { Keys *KeyStore } var _ Authenticator = APIKeyAuth{} func (a APIKeyAuth) Authenticate(r *http.Request) (*Principal, error) { presented := r.Header.Get("X-API-Key") if presented == "" { return nil, ErrNoCredentials } rec, ok := a.Keys.lookup(presented) if !ok { return nil, errors.New("auth: invalid api key") } return &Principal{Subject: rec.ID, Scopes: rec.Scopes, Kind: KindAPIKey}, nil }