package server import ( "context" "crypto/rand" "encoding/base64" "errors" "net/http" "time" "golang.org/x/crypto/argon2" "gitea.meghsakha.com/platform/tenant-registry/internal/store" ) // Plaintext key format: `bp_<32 base64 chars>`. Prefix stored for UI is // the first 11 chars (`bp_<8 chars>`). Hash is argon2id with sensible // dev params (raise in M6+ once we see the verify call rate in prod). const ( keyPrefix = "bp_" prefixLen = 11 // bp_ + 8 keyEntropyBy = 24 // 24 bytes → 32 base64 chars ) var ( argonTime uint32 = 1 argonMemory uint32 = 64 * 1024 argonThreads uint8 = 4 argonKeyLen uint32 = 32 ) type createAPIKeyReq struct { TenantID string `json:"tenant_id"` Name string `json:"name"` Product string `json:"product,omitempty"` Scopes []string `json:"scopes,omitempty"` CreatedBy string `json:"created_by,omitempty"` } type createAPIKeyResp struct { APIKey store.APIKey `json:"api_key"` Plaintext string `json:"plaintext"` // shown ONCE — caller must store WarningMsg string `json:"warning"` } func (s *Server) createAPIKey(w http.ResponseWriter, r *http.Request) { var in createAPIKeyReq if !decodeJSON(w, r, &in) { return } if in.TenantID == "" || in.Name == "" { writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id and name are required") return } if len(in.Name) > 100 { writeError(w, http.StatusBadRequest, "invalid_name", "name too long") return } if in.Product != "" && !isKnownProduct(in.Product) { writeError(w, http.StatusBadRequest, "unknown_product", "product is not in the catalog") return } ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() plain, err := generateAPIKey() if err != nil { writeError(w, http.StatusInternalServerError, "internal", "key generation failed") return } hash := hashAPIKey(plain) k, err := s.Store.CreateAPIKey(ctx, store.APIKeyCreate{ TenantID: in.TenantID, Product: in.Product, Name: in.Name, Scopes: in.Scopes, Prefix: plain[:prefixLen], Hash: hash, CreatedBy: in.CreatedBy, }) if err != nil { if mapStoreError(w, err) { return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } s.emitAudit(ctx, r, store.AuditEvent{ TenantID: in.TenantID, Action: "api_key.created", TargetID: k.ID, TargetType: "api_key", TargetName: in.Name, Metadata: map[string]interface{}{"product": in.Product, "scopes": in.Scopes}, }) writeJSON(w, http.StatusCreated, createAPIKeyResp{ APIKey: *k, Plaintext: plain, WarningMsg: "Store this value now — it cannot be retrieved later.", }) } func (s *Server) listAPIKeys(w http.ResponseWriter, r *http.Request) { tenantID := r.URL.Query().Get("tenant_id") if tenantID == "" { writeError(w, http.StatusBadRequest, "invalid_input", "tenant_id query param is required") return } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() list, err := s.Store.ListAPIKeys(ctx, tenantID) if err != nil { if mapStoreError(w, err) { return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } writeJSON(w, http.StatusOK, map[string]any{"items": list}) } func (s *Server) revokeAPIKey(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second) defer cancel() id := r.PathValue("id") if err := s.Store.RevokeAPIKey(ctx, id); err != nil { if mapStoreError(w, err) { return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } s.emitAudit(ctx, r, store.AuditEvent{ Action: "api_key.revoked", TargetID: id, TargetType: "api_key", }) w.WriteHeader(http.StatusNoContent) } type verifyAPIKeyReq struct { Key string `json:"key"` } type verifyAPIKeyResp struct { Valid bool `json:"valid"` TenantID string `json:"tenant_id,omitempty"` Product string `json:"product,omitempty"` Scopes []string `json:"scopes,omitempty"` } // verifyAPIKey — POST /v1/internal/api-keys/verify. Used by headless products // to validate inbound keys. Returns 200 with valid=false rather than 401 so // the caller can decide what to do. func (s *Server) verifyAPIKey(w http.ResponseWriter, r *http.Request) { var in verifyAPIKeyReq if !decodeJSON(w, r, &in) { return } if !looksLikeKey(in.Key) { writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false}) return } ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) defer cancel() k, hash, err := s.Store.FindAPIKeyByPrefix(ctx, in.Key[:prefixLen]) if err != nil { if errors.Is(err, store.ErrNotFound) { writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false}) return } writeError(w, http.StatusInternalServerError, "internal", err.Error()) return } if !verifyHash(in.Key, hash) { writeJSON(w, http.StatusOK, verifyAPIKeyResp{Valid: false}) return } // Best-effort touch — failures are non-fatal. if err := s.Store.TouchAPIKeyUsed(ctx, k.ID); err != nil { s.Log.Warn("touch api_key failed", "err", err) } writeJSON(w, http.StatusOK, verifyAPIKeyResp{ Valid: true, TenantID: k.TenantID, Product: k.Product, Scopes: k.Scopes, }) } // ─── helpers ────────────────────────────────────────────────────────────── func generateAPIKey() (string, error) { buf := make([]byte, keyEntropyBy) if _, err := rand.Read(buf); err != nil { return "", err } return keyPrefix + base64.RawURLEncoding.EncodeToString(buf), nil } func looksLikeKey(k string) bool { if len(k) < prefixLen { return false } if k[:len(keyPrefix)] != keyPrefix { return false } return true } func hashAPIKey(plain string) string { salt := make([]byte, 16) _, _ = rand.Read(salt) hash := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen) // Encode as $argon2id$v=19$m=...,t=...,p=...$salt$hash so we can shift // parameters later without re-keying. return "argon2id|" + base64.RawStdEncoding.EncodeToString(salt) + "|" + base64.RawStdEncoding.EncodeToString(hash) } func verifyHash(plain, stored string) bool { // Format: argon2id|| if len(stored) < 12 || stored[:9] != "argon2id|" { return false } rest := stored[9:] sep := -1 for i := range rest { if rest[i] == '|' { sep = i break } } if sep <= 0 { return false } salt, err := base64.RawStdEncoding.DecodeString(rest[:sep]) if err != nil { return false } want, err := base64.RawStdEncoding.DecodeString(rest[sep+1:]) if err != nil { return false } got := argon2.IDKey([]byte(plain), salt, argonTime, argonMemory, argonThreads, argonKeyLen) if len(want) != len(got) { return false } var diff byte for i := range want { diff |= want[i] ^ got[i] } return diff == 0 }