272 lines
8.6 KiB
Go
272 lines
8.6 KiB
Go
package licenseserver
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/subtle"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/b1tsblog/ai-disclosure-standard/pkg/licensekit"
|
|
)
|
|
|
|
type Config struct {
|
|
TrustStore licensekit.TrustStore
|
|
LeasePrivateKey ed25519.PrivateKey
|
|
LeaseKeyID string
|
|
AdminToken string
|
|
DefaultLeaseTTL time.Duration
|
|
MaxLeaseTTL time.Duration
|
|
}
|
|
|
|
type Registry interface {
|
|
Get(id string) (Record, bool)
|
|
List() []Record
|
|
Put(record Record) error
|
|
SetRevoked(id string, revoked bool, reason string) error
|
|
}
|
|
|
|
type Server struct {
|
|
cfg Config
|
|
store Registry
|
|
logger *slog.Logger
|
|
mux *http.ServeMux
|
|
}
|
|
|
|
type introspectRequest struct {
|
|
Token string `json:"token"`
|
|
Product string `json:"product"`
|
|
BaseURL string `json:"baseUrl"`
|
|
Host string `json:"host"`
|
|
InstanceID string `json:"instanceId,omitempty"`
|
|
ClientVersion string `json:"clientVersion,omitempty"`
|
|
}
|
|
|
|
type introspectResponse struct {
|
|
Valid bool `json:"valid"`
|
|
LeaseToken string `json:"leaseToken,omitempty"`
|
|
ExpiresAt string `json:"expiresAt,omitempty"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
type registerRequest struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
type revokeRequest struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
func New(cfg Config, store Registry, logger *slog.Logger) (*Server, error) {
|
|
if store == nil {
|
|
return nil, errors.New("license registry is required")
|
|
}
|
|
if len(cfg.LeasePrivateKey) != ed25519.PrivateKeySize {
|
|
return nil, errors.New("a valid Ed25519 lease signing private key is required")
|
|
}
|
|
if strings.TrimSpace(cfg.LeaseKeyID) == "" {
|
|
return nil, errors.New("lease key id is required")
|
|
}
|
|
if _, ok := cfg.TrustStore.LeaseKeys[cfg.LeaseKeyID]; !ok {
|
|
return nil, fmt.Errorf("lease public key %q is not present in the trust store", cfg.LeaseKeyID)
|
|
}
|
|
if cfg.DefaultLeaseTTL <= 0 {
|
|
cfg.DefaultLeaseTTL = time.Hour
|
|
}
|
|
if cfg.MaxLeaseTTL <= 0 {
|
|
cfg.MaxLeaseTTL = 24 * time.Hour
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
s := &Server{cfg: cfg, store: store, logger: logger, mux: http.NewServeMux()}
|
|
s.routes()
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Server) Handler() http.Handler { return s.securityHeaders(s.mux) }
|
|
|
|
func (s *Server) routes() {
|
|
s.mux.HandleFunc("GET /healthz", s.health)
|
|
s.mux.HandleFunc("POST /v1/introspect", s.introspect)
|
|
s.mux.HandleFunc("GET /v1/admin/licenses", s.admin(s.list))
|
|
s.mux.HandleFunc("POST /v1/admin/licenses", s.admin(s.register))
|
|
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/revoke", s.admin(s.revoke))
|
|
s.mux.HandleFunc("POST /v1/admin/licenses/{id}/restore", s.admin(s.restore))
|
|
}
|
|
|
|
func (s *Server) health(w http.ResponseWriter, _ *http.Request) {
|
|
s.writeJSON(w, http.StatusOK, map[string]any{"status": "ok"})
|
|
}
|
|
|
|
func (s *Server) introspect(w http.ResponseWriter, r *http.Request) {
|
|
var request introspectRequest
|
|
if err := decodeBody(r, &request); err != nil {
|
|
s.writeJSON(w, http.StatusBadRequest, introspectResponse{Reason: err.Error()})
|
|
return
|
|
}
|
|
now := time.Now().UTC()
|
|
verified, err := licensekit.VerifyLicense(s.cfg.TrustStore, request.Token, now)
|
|
if err != nil {
|
|
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: err.Error()})
|
|
return
|
|
}
|
|
claims := verified.Claims
|
|
if err := licensekit.ValidateLicenseContext(claims, request.Product, request.BaseURL, request.InstanceID); err != nil {
|
|
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: err.Error()})
|
|
return
|
|
}
|
|
record, ok := s.store.Get(claims.LicenseID)
|
|
if !ok {
|
|
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: "license is not registered"})
|
|
return
|
|
}
|
|
if record.TokenHash != licensekit.TokenHash(request.Token) {
|
|
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: "registered token does not match"})
|
|
return
|
|
}
|
|
if record.Revoked {
|
|
reason := "license is revoked"
|
|
if record.Reason != "" {
|
|
reason += ": " + record.Reason
|
|
}
|
|
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: reason})
|
|
return
|
|
}
|
|
ttl := s.cfg.DefaultLeaseTTL
|
|
if claims.Verification.LeaseTTLSeconds > 0 {
|
|
ttl = time.Duration(claims.Verification.LeaseTTLSeconds) * time.Second
|
|
}
|
|
if ttl > s.cfg.MaxLeaseTTL {
|
|
ttl = s.cfg.MaxLeaseTTL
|
|
}
|
|
if remaining := time.Until(time.Unix(claims.ExpiresAt, 0)); ttl > remaining {
|
|
ttl = remaining
|
|
}
|
|
if ttl <= 0 {
|
|
s.writeJSON(w, http.StatusForbidden, introspectResponse{Reason: "license has expired"})
|
|
return
|
|
}
|
|
host, _ := licensekit.HostFromBaseURL(request.BaseURL)
|
|
leaseID := randomID("lease")
|
|
leaseClaims := licensekit.LeaseClaims{
|
|
Version: 1, LeaseID: leaseID, LicenseID: claims.LicenseID, Product: claims.Product,
|
|
Customer: claims.Customer, Edition: claims.Edition, Features: claims.Features,
|
|
Host: host, InstanceID: request.InstanceID, IssuedAt: now.Unix(), ExpiresAt: now.Add(ttl).Unix(),
|
|
}
|
|
token, err := licensekit.SignLease(s.cfg.LeasePrivateKey, s.cfg.LeaseKeyID, leaseClaims)
|
|
if err != nil {
|
|
s.logger.Error("lease signing failed", "error", err, "license_id", claims.LicenseID)
|
|
s.writeJSON(w, http.StatusInternalServerError, introspectResponse{Reason: "lease signing failed"})
|
|
return
|
|
}
|
|
s.writeJSON(w, http.StatusOK, introspectResponse{Valid: true, LeaseToken: token, ExpiresAt: time.Unix(leaseClaims.ExpiresAt, 0).UTC().Format(time.RFC3339)})
|
|
}
|
|
|
|
func (s *Server) list(w http.ResponseWriter, _ *http.Request) {
|
|
s.writeJSON(w, http.StatusOK, map[string]any{"licenses": s.store.List()})
|
|
}
|
|
|
|
func (s *Server) register(w http.ResponseWriter, r *http.Request) {
|
|
var request registerRequest
|
|
if err := decodeBody(r, &request); err != nil {
|
|
s.writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
verified, err := licensekit.VerifyLicense(s.cfg.TrustStore, request.Token, time.Now().UTC())
|
|
if err != nil {
|
|
s.writeJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
claims := verified.Claims
|
|
record := Record{LicenseID: claims.LicenseID, TokenHash: licensekit.TokenHash(request.Token), Product: claims.Product, Customer: claims.Customer, Edition: claims.Edition, ExpiresAt: claims.ExpiresAt}
|
|
if err := s.store.Put(record); err != nil {
|
|
s.writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
s.writeJSON(w, http.StatusCreated, record)
|
|
}
|
|
|
|
func (s *Server) revoke(w http.ResponseWriter, r *http.Request) {
|
|
var request revokeRequest
|
|
_ = decodeBodyAllowEmpty(r, &request)
|
|
if err := s.store.SetRevoked(r.PathValue("id"), true, strings.TrimSpace(request.Reason)); err != nil {
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
s.writeJSON(w, http.StatusNotFound, map[string]any{"error": "license not found"})
|
|
return
|
|
}
|
|
s.writeJSON(w, http.StatusNotFound, map[string]any{"error": "license not found"})
|
|
return
|
|
}
|
|
s.writeJSON(w, http.StatusOK, map[string]any{"status": "revoked"})
|
|
}
|
|
|
|
func (s *Server) restore(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.store.SetRevoked(r.PathValue("id"), false, ""); err != nil {
|
|
s.writeJSON(w, http.StatusNotFound, map[string]any{"error": "license not found"})
|
|
return
|
|
}
|
|
s.writeJSON(w, http.StatusOK, map[string]any{"status": "active"})
|
|
}
|
|
|
|
func (s *Server) admin(next http.HandlerFunc) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
expected := strings.TrimSpace(s.cfg.AdminToken)
|
|
actual := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
|
if expected == "" || subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) != 1 {
|
|
w.Header().Set("WWW-Authenticate", "Bearer")
|
|
s.writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
|
|
return
|
|
}
|
|
next(w, r)
|
|
}
|
|
}
|
|
|
|
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.Header().Set("X-Frame-Options", "DENY")
|
|
w.Header().Set("Referrer-Policy", "no-referrer")
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|
|
|
|
func (s *Server) writeJSON(w http.ResponseWriter, status int, value any) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(value)
|
|
}
|
|
|
|
func decodeBody(r *http.Request, target any) error {
|
|
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func decodeBodyAllowEmpty(r *http.Request, target any) error {
|
|
err := decodeBody(r, target)
|
|
if errors.Is(err, io.EOF) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
func randomID(prefix string) string {
|
|
var raw [16]byte
|
|
if _, err := rand.Read(raw[:]); err != nil {
|
|
return prefix + "_" + fmt.Sprint(time.Now().UnixNano())
|
|
}
|
|
return prefix + "_" + hex.EncodeToString(raw[:])
|
|
}
|