diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ed392aa --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.env +data +bin +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..45150ad --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# Public URL used in signed hybrid/online licenses. +LICENSE_PUBLIC_URL=http://localhost:8091 +LICENSE_ADDRESS=:8091 +LICENSE_BRAND=Universal License Platform +LICENSE_DATA_FILE=/data/platform.json + +# Generate once: openssl rand -base64 32 +# Never rotate this value without decrypting and re-encrypting the data store. +LICENSE_MASTER_KEY= +# Or use Docker/Kubernetes secrets: +# LICENSE_MASTER_KEY_FILE=/run/secrets/license_master_key + +# Used only while the data store has no administrator account. +LICENSE_BOOTSTRAP_ADMIN_USER=admin +LICENSE_BOOTSTRAP_ADMIN_NAME=Administrator +LICENSE_BOOTSTRAP_ADMIN_PASSWORD= +# LICENSE_BOOTSTRAP_ADMIN_PASSWORD_FILE=/run/secrets/bootstrap_password + +# Optional bearer token for the management API. Use at least 32 random bytes. +LICENSE_ADMIN_API_TOKEN= +# LICENSE_ADMIN_API_TOKEN_FILE=/run/secrets/admin_api_token + +LICENSE_SESSION_TTL=12h +LICENSE_DEFAULT_LEASE_TTL=1h +LICENSE_MAX_LEASE_TTL=24h +LICENSE_SECURE_COOKIES=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2957fc2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.env +data/ +bin/ +*.log diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72483f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM golang:1.23-alpine AS build +WORKDIR /src +COPY go.mod ./ +COPY . . +RUN CGO_ENABLED=0 go test ./... && \ + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/license-platform ./cmd/server && \ + mkdir -p /out/data + +FROM gcr.io/distroless/static-debian12:nonroot +WORKDIR /app +COPY --from=build /out/license-platform /app/license-platform +COPY --from=build --chown=nonroot:nonroot /out/data /data +VOLUME ["/data"] +EXPOSE 8091 +USER nonroot:nonroot +ENTRYPOINT ["/app/license-platform"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..12feb38 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 AI Usage Disclosure contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6aac36b --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +.PHONY: run test check build docker-build + +run: + go run ./cmd/server + +test: + go test ./... + +check: + gofmt -w $$(find . -name '*.go' -type f) + go vet ./... + go test -race ./... + +build: + mkdir -p bin + CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/license-platform ./cmd/server + +docker-build: + docker build -t universal-license-platform:1.0.0-local . diff --git a/README.md b/README.md index 22cd499..a17521b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,162 @@ -# license-managent-system +# Universal License Platform +Eigenständiger Lizenz-Server mit drei getrennten Portalen, signierten Offline-/Hybrid-/Online-Lizenzen und einer wiederverwendbaren Go-Clientbibliothek. + +## Enthalten + +- **Admin / Management:** initialisiert die Write-once-Schlüssel, verwaltet Accounts, sieht alle Lizenzen und den Audit-Trail. +- **Reseller / Autor:** stellt Lizenzen aus und verwaltet ausschließlich die eigenen Datensätze, ohne Zugriff auf private Schlüssel. +- **Kunde / Client:** sieht die zugeordneten Lizenzen, Laufzeiten, Status und fertige Client-Konfigurationen. +- **Validierungs-API:** validiert signierte Lizenzen, prüft Sperrstatus und stellt kurzlebige, Ed25519-signierte Leases aus. +- **Hybrid offline:** ein Client verwendet bei temporärer Nichterreichbarkeit eine zuvor verifizierte Lease bis zum signierten Grace-Ende. +- **Client-SDK:** `sdk/go/licenseclient` für Feature Gates, Limits, Hintergrund-Refresh und sicheren Lease-Cache. + +## Sicherheitsmodell + +- Issuer- und Lease-Private-Keys werden mit **AES-256-GCM** verschlüsselt im Datenspeicher abgelegt. +- Lizenz-Tokens werden ebenfalls verschlüsselt gespeichert; zur Online-Prüfung wird zusätzlich nur ein SHA-256-Hash gebunden. +- Die Schlüsselerzeugung ist **write-once**. Sobald Schlüssel existieren, verschwindet der Button und das Backend lehnt jeden weiteren Generierungsversuch ab. +- Passwörter werden mit PBKDF2-HMAC-SHA256 und individuellem Salt gespeichert. +- Sessions sind HttpOnly, SameSite=Strict, zeitlich begrenzt und CSRF-geschützt. +- Login und Validierungs-API besitzen einfache Rate Limits. +- Sicherheitsheader, restriktive CSP und ein persistenter Audit-Trail sind aktiviert. +- Reseller- und Kundenzugriffe werden serverseitig nach Eigentum bzw. Zuordnung gefiltert. + +> Der JSON-Datenspeicher ist atomar und mit Dateimodus `0600` geschrieben, aber für eine einzelne Serverinstanz gedacht. Vor Active/Active-Betrieb sollte `Store` durch PostgreSQL oder eine andere transaktionale Datenbank ersetzt werden. + +## Schnellstart + +```bash +cd license-platform +cp .env.example .env +openssl rand -base64 32 # als LICENSE_MASTER_KEY eintragen +openssl rand -base64 32 # als LICENSE_ADMIN_API_TOKEN eintragen +# Ein langes Bootstrap-Passwort in LICENSE_BOOTSTRAP_ADMIN_PASSWORD eintragen. +docker compose up -d --build +``` + +Danach `http://localhost:8091` öffnen und mit dem Bootstrap-Admin anmelden. Der Bootstrap-Account wird nur angelegt, wenn der Datenspeicher noch keinen Administrator enthält. + +Im Admin-Portal werden die beiden Schlüsselpaare **einmalig** erzeugt. Private Schlüssel werden nie in der Oberfläche angezeigt. + +## Server-URL und automatische Erkennung + +Für Hybrid- und Online-Lizenzen schreibt die Plattform `LICENSE_PUBLIC_URL` signiert in `verification.serverUrl`. Das Go-SDK löst die URL in dieser Reihenfolge auf: + +1. `licenseclient.Config.ServerURL` +2. Environment `LICENSE_SERVER_URL` +3. signierte `verification.serverUrl` aus der Lizenz +4. `/.well-known/license-server` relativ zur Produkt-Base-URL + +Damit kann die URL weiterhin per ENV überschrieben werden, muss bei üblichen Installationen aber nicht doppelt gepflegt werden. + +## Clientbibliothek + +```go +package main + +import ( + "context" + "embed" + "os" + "time" + + "github.com/b1tsblog/license-platform/pkg/licensekit" + "github.com/b1tsblog/license-platform/sdk/go/licenseclient" +) + +//go:embed trusted-keys.json +var trustedKeys []byte + +func main() { + trust, err := licensekit.ParseTrustStore(trustedKeys) + if err != nil { + panic(err) + } + + client := licenseclient.New(context.Background(), licenseclient.Config{ + Product: "my-product", // im Produkt fest verdrahten + ClientVersion: "2.1.0", + Token: os.Getenv("LICENSE_TOKEN"), + TrustStore: trust, // nur Public Keys einbetten + BaseURL: "https://app.example.org", + InstanceID: os.Getenv("LICENSE_INSTANCE_ID"), + Mode: licensekit.ModeHybrid, + CacheFile: "/data/license-lease.json", + RefreshEvery: 15 * time.Minute, + RequestTimeout: 5 * time.Second, + // ServerURL ist optional: ENV oder signierter Token werden erkannt. + }) + client.Start(context.Background()) + defer client.Close() + + if client.Has("advanced_export") { + // Feature freischalten + } + if users, ok := client.Limit("users"); ok { + _ = users + } +} +``` + +Der Trust Store kann nach der Initialisierung unter `GET /api/v1/trust-store` geladen und in das Clientprodukt eingebettet werden. Ein Trust Store darf niemals kundenseitig frei konfigurierbar sein. + +## API + +Öffentlich: + +- `GET /.well-known/license-server` +- `GET /api/v1/trust-store` +- `POST /api/v1/licenses/validate` +- Kompatibilitätsalias: `POST /v1/introspect` + +Management mit `Authorization: Bearer $LICENSE_ADMIN_API_TOKEN`: + +- `GET /api/v1/licenses` +- `POST /api/v1/licenses` +- `POST /api/v1/licenses/import` for already signed tokens +- `POST /api/v1/licenses/{id}/revoke` +- `POST /api/v1/licenses/{id}/restore` + +Beispiel zur Validierung: + +```bash +curl -sS http://localhost:8091/api/v1/licenses/validate \ + -H 'Content-Type: application/json' \ + -d '{ + "token":"LICENSE_TOKEN", + "product":"my-product", + "baseUrl":"https://app.example.org", + "instanceId":"optional-instance" + }' +``` + +Die vollständige Beschreibung liegt in [`openapi.yaml`](openapi.yaml). Die bisherigen `/v1/admin/licenses`-Routen bleiben als Kompatibilitätsalias verfügbar, sodass das ältere `licenseweb` bestehende Tokens registrieren kann. + +Eine schrittweise Übernahme vorhandener Schlüssel und Lizenzen ist in [`docs/MIGRATION.md`](docs/MIGRATION.md) beschrieben. + +## ENV-Variablen + +| Variable | Zweck | +|---|---| +| `LICENSE_PUBLIC_URL` | Öffentliche Basis-URL; wird in Hybrid-/Online-Lizenzen signiert | +| `LICENSE_MASTER_KEY` / `_FILE` | Base64-kodierter 32-Byte-Schlüssel für AES-256-GCM | +| `LICENSE_BOOTSTRAP_ADMIN_USER` | initialer Admin-Benutzername | +| `LICENSE_BOOTSTRAP_ADMIN_PASSWORD` / `_FILE` | initiales Passwort, mindestens 12 Zeichen | +| `LICENSE_ADMIN_API_TOKEN` / `_FILE` | optionaler Management-API-Bearer | +| `LICENSE_DATA_FILE` | persistenter JSON-Datenspeicher | +| `LICENSE_SESSION_TTL` | Session-Laufzeit, Standard `12h` | +| `LICENSE_DEFAULT_LEASE_TTL` | Standard-Lease, `1h` | +| `LICENSE_MAX_LEASE_TTL` | serverseitiges Maximum, `24h` | +| `LICENSE_SECURE_COOKIES` | bei TLS `true`; HTTPS-URL aktiviert es automatisch | + +## Betrieb + +```bash +go test ./... +go vet ./... +go test -race ./... +go build ./cmd/server +``` + +Für produktive Installationen gehören `LICENSE_MASTER_KEY`, Bootstrap-Passwort und API-Token in Docker/Kubernetes Secrets. TLS sollte am Reverse Proxy terminiert werden; `LICENSE_PUBLIC_URL` muss dabei die externe HTTPS-URL enthalten. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..5050425 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,10 @@ +# Security notes + +- Never commit `.env`, the data store, `LICENSE_MASTER_KEY`, bootstrap credentials or API tokens. +- Back up the encrypted data store together with the master key in separate, access-controlled locations. +- Losing the master key makes private signing keys and stored license tokens unrecoverable. +- Do not change the master key in place. A controlled decrypt/re-encrypt migration is required. +- Publish the service only behind TLS and set `LICENSE_PUBLIC_URL` to the external HTTPS URL. +- Rotate user passwords and management API tokens independently of signing keys. +- Signing-key rotation is intentionally not an overwrite operation. Introduce a versioned key-rotation workflow and retain previous public keys until all old licenses expire. +- The bundled JSON store is single-node. Use a transactional shared store before horizontal scaling. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..aeb47cd --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,137 @@ +package main + +import ( + "context" + "errors" + "log/slog" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/b1tsblog/license-platform/internal/platform" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "--healthcheck" { + healthcheck() + return + } + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) + store, err := platform.OpenStore(env("LICENSE_DATA_FILE", "./data/platform.json")) + if err != nil { + logger.Error("open data store", "error", err) + os.Exit(1) + } + app, err := platform.New(platform.Config{ + Brand: env("LICENSE_BRAND", "License Platform"), + Address: env("LICENSE_ADDRESS", ":8090"), + PublicURL: env("LICENSE_PUBLIC_URL", "http://localhost:8090"), + DataFile: env("LICENSE_DATA_FILE", "./data/platform.json"), + MasterKey: secretEnv("LICENSE_MASTER_KEY"), + BootstrapUsername: env("LICENSE_BOOTSTRAP_ADMIN_USER", "admin"), + BootstrapPassword: secretEnv("LICENSE_BOOTSTRAP_ADMIN_PASSWORD"), + BootstrapName: env("LICENSE_BOOTSTRAP_ADMIN_NAME", "Administrator"), + AdminAPIToken: firstSecret("LICENSE_ADMIN_API_TOKEN", "LICENSE_SERVER_ADMIN_TOKEN"), + SessionTTL: durationEnv("LICENSE_SESSION_TTL", 12*time.Hour), + DefaultLeaseTTL: durationEnv("LICENSE_DEFAULT_LEASE_TTL", time.Hour), + MaxLeaseTTL: durationEnv("LICENSE_MAX_LEASE_TTL", 24*time.Hour), + SecureCookies: boolEnv("LICENSE_SECURE_COOKIES", false), + }, store, logger) + if err != nil { + logger.Error("create license platform", "error", err) + os.Exit(1) + } + address := env("LICENSE_ADDRESS", ":8090") + server := &http.Server{Addr: address, Handler: app.Handler(), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 20 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20} + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + go func() { + logger.Info("license platform started", "address", address, "public_url", env("LICENSE_PUBLIC_URL", "http://localhost:8090")) + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("license platform failed", "error", err) + os.Exit(1) + } + }() + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = server.Shutdown(shutdownCtx) +} + +func env(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func secretEnv(key string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + path := strings.TrimSpace(os.Getenv(key + "_FILE")) + if path == "" { + return "" + } + data, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(data)) +} + +func durationEnv(key string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + if duration, err := time.ParseDuration(value); err == nil { + return duration + } + if seconds, err := strconv.ParseInt(value, 10, 64); err == nil { + return time.Duration(seconds) * time.Second + } + return fallback +} + +func boolEnv(key string, fallback bool) bool { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return fallback + } + return parsed +} + +func healthcheck() { + address := env("LICENSE_ADDRESS", ":8090") + port := address + if index := strings.LastIndex(address, ":"); index >= 0 { + port = address[index:] + } + if !strings.HasPrefix(port, ":") { + port = ":" + port + } + client := &http.Client{Timeout: 3 * time.Second} + resp, err := client.Get("http://127.0.0.1" + port + "/healthz") + if err != nil || resp.StatusCode != http.StatusOK { + os.Exit(1) + } + _ = resp.Body.Close() +} + +func firstSecret(keys ...string) string { + for _, key := range keys { + if value := secretEnv(key); value != "" { + return value + } + } + return "" +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..6564905 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,30 @@ +services: + license-platform: + build: . + image: universal-license-platform:1.0.0-local + env_file: + - .env + environment: + LICENSE_ADDRESS: :8090 + LICENSE_DATA_FILE: /data/platform.json + ports: + - "8090:8090" + volumes: + - license-platform-data:/data + read_only: true + tmpfs: + - /tmp:size=16m,mode=1777 + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: unless-stopped + healthcheck: + test: ["CMD", "/app/license-platform", "--healthcheck"] + interval: 20s + timeout: 4s + retries: 3 + start_period: 10s + +volumes: + license-platform-data: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..4366a1c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,23 @@ +# Architecture + +```text +Product + Go SDK + ├─ local Ed25519 license verification + ├─ feature/limit gates + ├─ signed lease cache (hybrid offline) + └─ POST /api/v1/licenses/validate + │ + ▼ +License Platform + ├─ Admin / Management portal + ├─ Reseller / Author portal + ├─ Customer / Client portal + ├─ encrypted issuer + lease key vault + ├─ encrypted license-token registry + ├─ revocation and lease service + └─ audit trail / role isolation +``` + +The issuer key signs long-lived product licenses. The lease key signs short-lived runtime confirmations. Clients embed only public keys. The management UI never returns either private key. + +The platform intentionally uses a `Store` boundary. The included JSON implementation is safe for one process and atomic file replacement. A database implementation can preserve the portal and API layers while adding transactions, replication and tenant indexing. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..b1d2b88 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,14 @@ +# Migration from the embedded reference server + +1. Deploy the standalone platform with a persistent data volume, a new `LICENSE_MASTER_KEY`, and bootstrap credentials. +2. Sign in as administrator. +3. In **Schlüsselverwaltung**, open **Bestehende Schlüsselpaare sicher importieren** and provide the existing issuer and lease private keys plus their original key IDs. This action is write-once. +4. Download `GET /api/v1/trust-store` and update the public trust store embedded in each product build. +5. Register existing, still-valid license tokens through one of these routes: + - `POST /api/v1/licenses/import` with `{"token":"...","customerUserId":"optional"}`; + - legacy-compatible `POST /v1/admin/licenses` with `{"token":"..."}`; + - the existing local `licenseweb`, pointed at the new platform URL and management bearer token. +6. Set `LICENSE_PUBLIC_URL` to the stable external HTTPS URL. Newly issued hybrid/online licenses will contain this URL as a signed claim. +7. Existing licenses without `verification.serverUrl` continue to work when clients receive `LICENSE_SERVER_URL` or well-known discovery. + +Do not generate new keys when migrating existing licenses. A new issuer key would make old tokens unverifiable unless both old and new public keys are retained in the product trust store. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..46cb7ac --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/b1tsblog/license-platform + +go 1.26 diff --git a/internal/platform/auth.go b/internal/platform/auth.go new file mode 100644 index 0000000..4372f6d --- /dev/null +++ b/internal/platform/auth.go @@ -0,0 +1,129 @@ +package platform + +import ( + "errors" + "net" + "net/http" + "strings" + "time" +) + +const ( + sessionCookie = "lp_session" + loginCSRFCookie = "lp_login_csrf" +) + +func (s *Server) currentUser(r *http.Request) (User, Session, bool) { + cookie, err := r.Cookie(sessionCookie) + if err != nil || strings.TrimSpace(cookie.Value) == "" { + return User{}, Session{}, false + } + session, ok := s.store.GetSession(tokenHash(cookie.Value), time.Now().UTC()) + if !ok { + return User{}, Session{}, false + } + user, ok := s.store.GetUser(session.UserID) + if !ok || !user.Active { + return User{}, Session{}, false + } + return user, session, true +} + +func (s *Server) requireUser(w http.ResponseWriter, r *http.Request) (User, Session, bool) { + user, session, ok := s.currentUser(r) + if !ok { + http.Redirect(w, r, "/login", http.StatusSeeOther) + return User{}, Session{}, false + } + return user, session, true +} + +func (s *Server) requireRole(w http.ResponseWriter, r *http.Request, roles ...Role) (User, Session, bool) { + user, session, ok := s.requireUser(w, r) + if !ok { + return User{}, Session{}, false + } + for _, role := range roles { + if user.Role == role { + return user, session, true + } + } + http.Error(w, "forbidden", http.StatusForbidden) + return User{}, Session{}, false +} + +func (s *Server) verifyCSRF(r *http.Request, session Session) bool { + if err := r.ParseForm(); err != nil { + return false + } + return constantTokenEqual(session.CSRFToken, r.FormValue("csrf")) +} + +func (s *Server) newSession(user User) (string, Session, error) { + raw, err := randomToken(32) + if err != nil { + return "", Session{}, err + } + csrf, err := randomToken(24) + if err != nil { + return "", Session{}, err + } + now := time.Now().UTC() + session := Session{IDHash: tokenHash(raw), UserID: user.ID, CSRFToken: csrf, CreatedAt: now.Unix(), ExpiresAt: now.Add(s.cfg.SessionTTL).Unix()} + if err := s.store.CreateSession(session); err != nil { + return "", Session{}, err + } + return raw, session, nil +} + +func (s *Server) setSessionCookie(w http.ResponseWriter, value string, expires time.Time) { + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, Expires: expires, MaxAge: int(time.Until(expires).Seconds())}) +} + +func (s *Server) clearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, MaxAge: -1, Expires: time.Unix(0, 0)}) +} + +func (s *Server) loginCSRF(w http.ResponseWriter, r *http.Request) string { + if cookie, err := r.Cookie(loginCSRFCookie); err == nil && len(cookie.Value) >= 20 { + return cookie.Value + } + value, _ := randomToken(24) + http.SetCookie(w, &http.Cookie{Name: loginCSRFCookie, Value: value, Path: "/login", HttpOnly: true, Secure: s.cfg.SecureCookies, SameSite: http.SameSiteStrictMode, MaxAge: 600}) + return value +} + +func (s *Server) checkLoginCSRF(r *http.Request) bool { + cookie, err := r.Cookie(loginCSRFCookie) + if err != nil { + return false + } + return constantTokenEqual(cookie.Value, r.FormValue("csrf")) +} + +func remoteIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil { + return host + } + return r.RemoteAddr +} + +func bearerToken(r *http.Request) string { + value := strings.TrimSpace(r.Header.Get("Authorization")) + if len(value) < 8 || !strings.EqualFold(value[:7], "Bearer ") { + return "" + } + return strings.TrimSpace(value[7:]) +} + +func (s *Server) apiActor(r *http.Request) (User, error) { + if token := bearerToken(r); token != "" && constantTokenEqual(s.cfg.AdminAPIToken, token) { + return User{ID: "api_admin", Username: "api", DisplayName: "Management API", Role: RoleAdmin, Active: true}, nil + } + user, _, ok := s.currentUser(r) + if ok && (user.Role == RoleAdmin || user.Role == RoleReseller) { + return user, nil + } + return User{}, errors.New("unauthorized") +} diff --git a/internal/platform/config.go b/internal/platform/config.go new file mode 100644 index 0000000..3334bf8 --- /dev/null +++ b/internal/platform/config.go @@ -0,0 +1,57 @@ +package platform + +import ( + "errors" + "net/url" + "strings" + "time" +) + +type Config struct { + Brand string + Address string + PublicURL string + DataFile string + MasterKey string + BootstrapUsername string + BootstrapPassword string + BootstrapName string + AdminAPIToken string + SessionTTL time.Duration + DefaultLeaseTTL time.Duration + MaxLeaseTTL time.Duration + SecureCookies bool +} + +func (c *Config) normalize() error { + if strings.TrimSpace(c.Brand) == "" { + c.Brand = "License Platform" + } + if strings.TrimSpace(c.Address) == "" { + c.Address = ":8091" + } + c.PublicURL = strings.TrimRight(strings.TrimSpace(c.PublicURL), "/") + if c.PublicURL == "" { + return errors.New("LICENSE_PUBLIC_URL is required") + } + parsed, err := url.Parse(c.PublicURL) + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return errors.New("LICENSE_PUBLIC_URL must be an absolute HTTP(S) URL") + } + if token := strings.TrimSpace(c.AdminAPIToken); token != "" && len(token) < 32 { + return errors.New("LICENSE_ADMIN_API_TOKEN must contain at least 32 characters when configured") + } + if c.SessionTTL <= 0 { + c.SessionTTL = 12 * time.Hour + } + if c.DefaultLeaseTTL <= 0 { + c.DefaultLeaseTTL = time.Hour + } + if c.MaxLeaseTTL <= 0 { + c.MaxLeaseTTL = 24 * time.Hour + } + if parsed.Scheme == "https" { + c.SecureCookies = true + } + return nil +} diff --git a/internal/platform/licensing.go b/internal/platform/licensing.go new file mode 100644 index 0000000..fe33b48 --- /dev/null +++ b/internal/platform/licensing.go @@ -0,0 +1,431 @@ +package platform + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "errors" + "fmt" + "net/url" + "sort" + "strconv" + "strings" + "time" + + "github.com/b1tsblog/license-platform/pkg/licensekit" +) + +type IssueInput struct { + CustomerUserID string `json:"customerUserId,omitempty"` + Customer string `json:"customer"` + Product string `json:"product"` + Edition string `json:"edition"` + Features []string `json:"features,omitempty"` + Limits map[string]int64 `json:"limits,omitempty"` + Domains []string `json:"domains,omitempty"` + InstanceIDs []string `json:"instanceIds,omitempty"` + Mode string `json:"mode"` + Days int `json:"days"` + LeaseMinutes int `json:"leaseMinutes"` + GraceHours int `json:"graceHours"` +} + +type LicenseView struct { + LicenseID string `json:"licenseId"` + Issuer string `json:"issuer"` + Customer string `json:"customer"` + CustomerUserID string `json:"customerUserId,omitempty"` + Product string `json:"product"` + Edition string `json:"edition"` + Features []string `json:"features,omitempty"` + Limits map[string]int64 `json:"limits,omitempty"` + Domains []string `json:"domains,omitempty"` + InstanceIDs []string `json:"instanceIds,omitempty"` + Mode string `json:"mode"` + ServerURL string `json:"serverUrl,omitempty"` + ExpiresAt int64 `json:"expiresAt"` + Revoked bool `json:"revoked"` + Reason string `json:"reason,omitempty"` + Token string `json:"token,omitempty"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +type validateRequest struct { + Token string `json:"token"` + Product string `json:"product"` + BaseURL string `json:"baseUrl"` + Host string `json:"host,omitempty"` + InstanceID string `json:"instanceId,omitempty"` + ClientVersion string `json:"clientVersion,omitempty"` +} + +type validateResponse struct { + Valid bool `json:"valid"` + LeaseToken string `json:"leaseToken,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func (s *Server) generateKeys(issuerKeyID, leaseKeyID string) error { + issuerKeyID = strings.TrimSpace(issuerKeyID) + leaseKeyID = strings.TrimSpace(leaseKeyID) + if !validKeyID(issuerKeyID) || !validKeyID(leaseKeyID) { + return errors.New("key IDs must be 1-120 URL-safe characters") + } + issuerPub, issuerPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + leasePub, leasePriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return err + } + issuerCipher, err := s.vault.Encrypt(licensekit.EncodeKey(issuerPriv)) + if err != nil { + return err + } + leaseCipher, err := s.vault.Encrypt(licensekit.EncodeKey(leasePriv)) + if err != nil { + return err + } + return s.store.SetKeysOnce(KeySet{ + IssuerKeyID: issuerKeyID, IssuerPublicKey: licensekit.EncodeKey(issuerPub), IssuerPrivateCipher: issuerCipher, + LeaseKeyID: leaseKeyID, LeasePublicKey: licensekit.EncodeKey(leasePub), LeasePrivateCipher: leaseCipher, + }) +} + +func (s *Server) importKeys(issuerKeyID, issuerPrivateEncoded, leaseKeyID, leasePrivateEncoded string) error { + issuerKeyID = strings.TrimSpace(issuerKeyID) + leaseKeyID = strings.TrimSpace(leaseKeyID) + if !validKeyID(issuerKeyID) || !validKeyID(leaseKeyID) { + return errors.New("key IDs must be 1-120 URL-safe characters") + } + issuerPrivate, err := licensekit.DecodePrivateKey(strings.TrimSpace(issuerPrivateEncoded)) + if err != nil { + return fmt.Errorf("issuer private key: %w", err) + } + leasePrivate, err := licensekit.DecodePrivateKey(strings.TrimSpace(leasePrivateEncoded)) + if err != nil { + return fmt.Errorf("lease private key: %w", err) + } + issuerCipher, err := s.vault.Encrypt(licensekit.EncodeKey(issuerPrivate)) + if err != nil { + return err + } + leaseCipher, err := s.vault.Encrypt(licensekit.EncodeKey(leasePrivate)) + if err != nil { + return err + } + return s.store.SetKeysOnce(KeySet{ + IssuerKeyID: issuerKeyID, IssuerPublicKey: licensekit.EncodeKey(issuerPrivate.Public().(ed25519.PublicKey)), IssuerPrivateCipher: issuerCipher, + LeaseKeyID: leaseKeyID, LeasePublicKey: licensekit.EncodeKey(leasePrivate.Public().(ed25519.PublicKey)), LeasePrivateCipher: leaseCipher, + }) +} + +func (s *Server) trustStore() (licensekit.TrustStore, error) { + keys, ok := s.store.KeySet() + if !ok { + return licensekit.TrustStore{}, errors.New("key pairs have not been initialized") + } + store := licensekit.NewTrustStore() + store.LicenseKeys[keys.IssuerKeyID] = keys.IssuerPublicKey + store.LeaseKeys[keys.LeaseKeyID] = keys.LeasePublicKey + return store, nil +} + +func (s *Server) issueLicense(actor User, input IssueInput) (LicenseRecord, string, error) { + if actor.Role != RoleAdmin && actor.Role != RoleReseller { + return LicenseRecord{}, "", errors.New("issuer role required") + } + keys, ok := s.store.KeySet() + if !ok { + return LicenseRecord{}, "", errors.New("key pairs have not been initialized") + } + input.Customer = strings.TrimSpace(input.Customer) + input.Product = strings.TrimSpace(input.Product) + input.Edition = strings.TrimSpace(input.Edition) + if input.Customer == "" || input.Product == "" || input.Edition == "" { + return LicenseRecord{}, "", errors.New("customer, product and edition are required") + } + if len(input.Customer) > 300 || len(input.Product) > 200 || len(input.Edition) > 100 { + return LicenseRecord{}, "", errors.New("customer, product or edition is too long") + } + for _, value := range append(append([]string{}, input.Features...), append(input.Domains, input.InstanceIDs...)...) { + if len(strings.TrimSpace(value)) > 300 { + return LicenseRecord{}, "", errors.New("feature, domain or instance value is too long") + } + } + mode, err := licensekit.ParseMode(input.Mode) + if err != nil { + return LicenseRecord{}, "", err + } + if input.Days < 1 || input.Days > 3650 { + return LicenseRecord{}, "", errors.New("days must be between 1 and 3650") + } + if input.LeaseMinutes <= 0 { + input.LeaseMinutes = 60 + } + if input.LeaseMinutes > 1440 || input.GraceHours < 0 || input.GraceHours > 8760 { + return LicenseRecord{}, "", errors.New("lease or grace duration is outside the allowed range") + } + if input.CustomerUserID != "" { + customerUser, ok := s.store.GetUser(input.CustomerUserID) + if !ok || customerUser.Role != RoleCustomer || !customerUser.Active { + return LicenseRecord{}, "", errors.New("customer portal account was not found") + } + if actor.Role == RoleReseller && customerUser.ParentUserID != actor.ID { + return LicenseRecord{}, "", errors.New("customer portal account is not assigned to this reseller") + } + } + privateEncoded, err := s.vault.Decrypt(keys.IssuerPrivateCipher) + if err != nil { + return LicenseRecord{}, "", err + } + privateKey, err := licensekit.DecodePrivateKey(privateEncoded) + if err != nil { + return LicenseRecord{}, "", err + } + now := time.Now().UTC() + licenseID := newID("lic") + serverURL := "" + if mode != licensekit.ModeOffline { + serverURL = s.cfg.PublicURL + } + claims := licensekit.Claims{ + Version: 1, LicenseID: licenseID, Issuer: actor.DisplayName, Customer: input.Customer, + Product: input.Product, Edition: input.Edition, Features: unique(input.Features), Limits: input.Limits, + Domains: unique(input.Domains), InstanceIDs: unique(input.InstanceIDs), IssuedAt: now.Unix(), + ExpiresAt: now.Add(time.Duration(input.Days) * 24 * time.Hour).Unix(), + Verification: licensekit.VerificationPolicy{Mode: mode, LeaseTTLSeconds: int64(time.Duration(input.LeaseMinutes) * time.Minute / time.Second), OfflineGraceSeconds: int64(time.Duration(input.GraceHours) * time.Hour / time.Second), ServerURL: serverURL}, + Metadata: map[string]string{"issuedByUserId": actor.ID}, + } + token, err := licensekit.SignLicense(privateKey, keys.IssuerKeyID, claims) + if err != nil { + return LicenseRecord{}, "", err + } + ciphertext, err := s.vault.Encrypt(token) + if err != nil { + return LicenseRecord{}, "", err + } + record := LicenseRecord{ + LicenseID: licenseID, TokenCipher: ciphertext, TokenHash: licensekit.TokenHash(token), Issuer: claims.Issuer, + Customer: claims.Customer, CustomerUserID: input.CustomerUserID, IssuedByUserID: actor.ID, + Product: claims.Product, Edition: claims.Edition, Features: claims.Features, Limits: claims.Limits, + Domains: claims.Domains, InstanceIDs: claims.InstanceIDs, Mode: string(mode), ServerURL: serverURL, ExpiresAt: claims.ExpiresAt, + } + if err := s.store.PutLicense(record); err != nil { + return LicenseRecord{}, "", err + } + stored, _ := s.store.GetLicense(licenseID) + return stored, token, nil +} + +func (s *Server) importLicense(actor User, token, customerUserID string) (LicenseRecord, error) { + if actor.Role != RoleAdmin && actor.Role != RoleReseller { + return LicenseRecord{}, errors.New("issuer role required") + } + token = strings.TrimSpace(token) + if token == "" { + return LicenseRecord{}, errors.New("license token is required") + } + trust, err := s.trustStore() + if err != nil { + return LicenseRecord{}, err + } + verified, err := licensekit.VerifyLicense(trust, token, time.Now().UTC()) + if err != nil { + return LicenseRecord{}, err + } + claims := verified.Claims + if existing, ok := s.store.GetLicense(claims.LicenseID); ok { + if existing.TokenHash == licensekit.TokenHash(token) { + return existing, nil + } + return LicenseRecord{}, errors.New("license ID already exists with another token") + } + if customerUserID != "" { + customer, ok := s.store.GetUser(customerUserID) + if !ok || customer.Role != RoleCustomer || !customer.Active { + return LicenseRecord{}, errors.New("customer portal account was not found") + } + if actor.Role == RoleReseller && customer.ParentUserID != actor.ID { + return LicenseRecord{}, errors.New("customer portal account is not assigned to this reseller") + } + } + ciphertext, err := s.vault.Encrypt(token) + if err != nil { + return LicenseRecord{}, err + } + record := LicenseRecord{ + LicenseID: claims.LicenseID, TokenCipher: ciphertext, TokenHash: licensekit.TokenHash(token), + Issuer: claims.Issuer, Customer: claims.Customer, CustomerUserID: customerUserID, IssuedByUserID: actor.ID, + Product: claims.Product, Edition: claims.Edition, Features: claims.Features, Limits: claims.Limits, + Domains: claims.Domains, InstanceIDs: claims.InstanceIDs, Mode: string(claims.Verification.Mode), + ServerURL: strings.TrimRight(strings.TrimSpace(claims.Verification.ServerURL), "/"), ExpiresAt: claims.ExpiresAt, + } + if err := s.store.PutLicense(record); err != nil { + return LicenseRecord{}, err + } + stored, _ := s.store.GetLicense(record.LicenseID) + return stored, nil +} + +func (s *Server) tokenFor(record LicenseRecord) (string, error) { + return s.vault.Decrypt(record.TokenCipher) +} + +func (s *Server) validateLicense(request validateRequest) (validateResponse, error) { + store, err := s.trustStore() + if err != nil { + return validateResponse{Reason: err.Error()}, err + } + now := time.Now().UTC() + verified, err := licensekit.VerifyLicense(store, request.Token, now) + if err != nil { + return validateResponse{Reason: err.Error()}, err + } + claims := verified.Claims + if err := licensekit.ValidateLicenseContext(claims, request.Product, request.BaseURL, request.InstanceID); err != nil { + return validateResponse{Reason: err.Error()}, err + } + record, ok := s.store.GetLicense(claims.LicenseID) + if !ok { + err := errors.New("license is not registered") + return validateResponse{Reason: err.Error()}, err + } + if record.TokenHash != licensekit.TokenHash(request.Token) { + err := errors.New("registered token does not match") + return validateResponse{Reason: err.Error()}, err + } + if record.Revoked { + reason := "license is revoked" + if record.RevocationReason != "" { + reason += ": " + record.RevocationReason + } + return validateResponse{Reason: reason}, errors.New(reason) + } + keys, _ := s.store.KeySet() + leaseEncoded, err := s.vault.Decrypt(keys.LeasePrivateCipher) + if err != nil { + return validateResponse{Reason: "lease signing unavailable"}, err + } + leaseKey, err := licensekit.DecodePrivateKey(leaseEncoded) + if err != nil { + return validateResponse{Reason: "lease signing unavailable"}, err + } + 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 { + return validateResponse{Reason: "license has expired"}, errors.New("license has expired") + } + host, _ := licensekit.HostFromBaseURL(request.BaseURL) + leaseClaims := licensekit.LeaseClaims{Version: 1, LeaseID: newID("lease"), 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()} + lease, err := licensekit.SignLease(leaseKey, keys.LeaseKeyID, leaseClaims) + if err != nil { + return validateResponse{Reason: "lease signing failed"}, err + } + return validateResponse{Valid: true, LeaseToken: lease, ExpiresAt: time.Unix(leaseClaims.ExpiresAt, 0).UTC().Format(time.RFC3339)}, nil +} + +func licenseView(record LicenseRecord, token string) LicenseView { + return LicenseView{LicenseID: record.LicenseID, Issuer: record.Issuer, Customer: record.Customer, CustomerUserID: record.CustomerUserID, Product: record.Product, Edition: record.Edition, Features: record.Features, Limits: record.Limits, Domains: record.Domains, InstanceIDs: record.InstanceIDs, Mode: record.Mode, ServerURL: record.ServerURL, ExpiresAt: record.ExpiresAt, Revoked: record.Revoked, Reason: record.RevocationReason, Token: token, CreatedAt: record.CreatedAt, UpdatedAt: record.UpdatedAt} +} + +func parseIssueForm(values url.Values) (IssueInput, error) { + days, err := parseBoundedInt(values.Get("days"), 1, 3650) + if err != nil { + return IssueInput{}, fmt.Errorf("days: %w", err) + } + leaseMinutes, err := parseBoundedInt(values.Get("leaseMinutes"), 1, 1440) + if err != nil { + return IssueInput{}, fmt.Errorf("lease minutes: %w", err) + } + graceHours, err := parseBoundedInt(values.Get("graceHours"), 0, 8760) + if err != nil { + return IssueInput{}, fmt.Errorf("grace hours: %w", err) + } + limits, err := parseLimits(values.Get("limits")) + if err != nil { + return IssueInput{}, err + } + return IssueInput{CustomerUserID: strings.TrimSpace(values.Get("customerUserId")), Customer: values.Get("customer"), Product: values.Get("product"), Edition: values.Get("edition"), Features: csv(values.Get("features")), Limits: limits, Domains: csv(values.Get("domains")), InstanceIDs: csv(values.Get("instances")), Mode: values.Get("mode"), Days: days, LeaseMinutes: leaseMinutes, GraceHours: graceHours}, nil +} + +func parseBoundedInt(value string, min, max int) (int, error) { + n, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || n < min || n > max { + return 0, fmt.Errorf("must be between %d and %d", min, max) + } + return n, nil +} + +func parseLimits(value string) (map[string]int64, error) { + limits := map[string]int64{} + for _, line := range strings.Split(value, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" { + return nil, fmt.Errorf("invalid limit %q; expected name=value", line) + } + value, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64) + if err != nil || value < 0 { + return nil, fmt.Errorf("invalid limit %q", line) + } + limits[strings.TrimSpace(parts[0])] = value + } + return limits, nil +} + +func csv(value string) []string { return unique(strings.Split(value, ",")) } + +func unique(values []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" && !seen[value] { + seen[value] = true + out = append(out, value) + } + } + sort.Strings(out) + return out +} + +func newID(prefix string) string { + raw, err := randomToken(12) + if err != nil { + return fmt.Sprintf("%s_%d", prefix, time.Now().UTC().UnixNano()) + } + return prefix + "_" + raw +} + +func trustStoreJSON(store licensekit.TrustStore) string { + data, _ := json.MarshalIndent(store, "", " ") + return string(data) +} + +func validKeyID(value string) bool { + if len(value) < 1 || len(value) > 120 { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' || r == ':' { + continue + } + return false + } + return true +} diff --git a/internal/platform/models.go b/internal/platform/models.go new file mode 100644 index 0000000..2044718 --- /dev/null +++ b/internal/platform/models.go @@ -0,0 +1,85 @@ +package platform + +import "time" + +type Role string + +const ( + RoleAdmin Role = "admin" + RoleReseller Role = "reseller" + RoleCustomer Role = "customer" +) + +type User struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Role Role `json:"role"` + ParentUserID string `json:"parentUserId,omitempty"` + PasswordHash string `json:"passwordHash"` + Active bool `json:"active"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +type KeySet struct { + IssuerKeyID string `json:"issuerKeyId"` + IssuerPublicKey string `json:"issuerPublicKey"` + IssuerPrivateCipher string `json:"issuerPrivateCipher"` + LeaseKeyID string `json:"leaseKeyId"` + LeasePublicKey string `json:"leasePublicKey"` + LeasePrivateCipher string `json:"leasePrivateCipher"` + CreatedAt int64 `json:"createdAt"` +} + +type LicenseRecord struct { + LicenseID string `json:"licenseId"` + TokenCipher string `json:"tokenCipher"` + TokenHash string `json:"tokenHash"` + Issuer string `json:"issuer"` + Customer string `json:"customer"` + CustomerUserID string `json:"customerUserId,omitempty"` + IssuedByUserID string `json:"issuedByUserId"` + Product string `json:"product"` + Edition string `json:"edition"` + Features []string `json:"features,omitempty"` + Limits map[string]int64 `json:"limits,omitempty"` + Domains []string `json:"domains,omitempty"` + InstanceIDs []string `json:"instanceIds,omitempty"` + Mode string `json:"mode"` + ServerURL string `json:"serverUrl,omitempty"` + ExpiresAt int64 `json:"expiresAt"` + Revoked bool `json:"revoked"` + RevocationReason string `json:"revocationReason,omitempty"` + CreatedAt int64 `json:"createdAt"` + UpdatedAt int64 `json:"updatedAt"` +} + +type Session struct { + IDHash string `json:"idHash"` + UserID string `json:"userId"` + CSRFToken string `json:"csrfToken"` + ExpiresAt int64 `json:"expiresAt"` + CreatedAt int64 `json:"createdAt"` +} + +type AuditEvent struct { + ID string `json:"id"` + ActorID string `json:"actorId,omitempty"` + Action string `json:"action"` + Target string `json:"target,omitempty"` + Detail string `json:"detail,omitempty"` + RemoteIP string `json:"remoteIp,omitempty"` + CreatedAt int64 `json:"createdAt"` +} + +type document struct { + Version int `json:"version"` + Keys *KeySet `json:"keys,omitempty"` + Users map[string]User `json:"users"` + Licenses map[string]LicenseRecord `json:"licenses"` + Sessions map[string]Session `json:"sessions"` + Audit []AuditEvent `json:"audit"` +} + +func unixNow() int64 { return time.Now().UTC().Unix() } diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 0000000..b91c461 --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,205 @@ +package platform + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" + + "github.com/b1tsblog/license-platform/pkg/licensekit" +) + +func testServer(t *testing.T, store *Store) *Server { + t.Helper() + master := base64.RawStdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32)) + server, err := New(Config{Brand: "Test Platform", PublicURL: "https://licenses.example.test", MasterKey: master, BootstrapUsername: "admin", BootstrapPassword: "a-very-long-test-password", AdminAPIToken: "api-secret-0123456789-0123456789-ab", SessionTTL: time.Hour}, store, slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + return server +} + +func TestPasswordHash(t *testing.T) { + hash, err := HashPassword("a-very-long-password") + if err != nil { + t.Fatal(err) + } + if !VerifyPassword(hash, "a-very-long-password") || VerifyPassword(hash, "wrong-password") { + t.Fatal("password verification mismatch") + } +} + +func TestKeysAreWriteOnceAndEncrypted(t *testing.T) { + path := t.TempDir() + "/platform.json" + store, err := OpenStore(path) + if err != nil { + t.Fatal(err) + } + server := testServer(t, store) + if err := server.generateKeys("issuer", "lease"); err != nil { + t.Fatal(err) + } + if err := server.generateKeys("issuer-2", "lease-2"); err != ErrKeysLocked { + t.Fatalf("expected ErrKeysLocked, got %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "private") && strings.Contains(string(data), "MC4CAQ") { + t.Fatal("data store appears to contain a plaintext private key") + } + keys, ok := store.KeySet() + if !ok || !strings.HasPrefix(keys.IssuerPrivateCipher, "v1.") || !strings.HasPrefix(keys.LeasePrivateCipher, "v1.") { + t.Fatal("private keys are not encrypted") + } +} + +func TestIssueValidateAndRevoke(t *testing.T) { + store, _ := OpenStore("") + server := testServer(t, store) + if err := server.generateKeys("issuer", "lease"); err != nil { + t.Fatal(err) + } + admin, ok := store.FindUserByUsername("admin") + if !ok { + t.Fatal("bootstrap admin missing") + } + record, token, err := server.issueLicense(admin, IssueInput{Customer: "ACME", Product: "product", Edition: "pro", Features: []string{"feature"}, Domains: []string{"*"}, Mode: "hybrid", Days: 30, LeaseMinutes: 60, GraceHours: 72}) + if err != nil { + t.Fatal(err) + } + trust, _ := server.trustStore() + verified, err := licensekit.VerifyLicense(trust, token, time.Now().UTC()) + if err != nil { + t.Fatal(err) + } + if verified.Claims.Verification.ServerURL != "https://licenses.example.test" { + t.Fatalf("unexpected embedded server URL %q", verified.Claims.Verification.ServerURL) + } + response, err := server.validateLicense(validateRequest{Token: token, Product: "product", BaseURL: "https://app.example.test"}) + if err != nil || !response.Valid || response.LeaseToken == "" { + t.Fatalf("validation failed: %#v %v", response, err) + } + if err := store.SetRevoked(record.LicenseID, true, "payment"); err != nil { + t.Fatal(err) + } + if _, err := server.validateLicense(validateRequest{Token: token, Product: "product", BaseURL: "https://app.example.test"}); err == nil { + t.Fatal("revoked license was accepted") + } +} + +func TestManagementAndValidationAPI(t *testing.T) { + store, _ := OpenStore("") + server := testServer(t, store) + if err := server.generateKeys("issuer", "lease"); err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(server.Handler()) + defer ts.Close() + issue := IssueInput{Customer: "API Customer", Product: "api-product", Edition: "team", Domains: []string{"*"}, Mode: "hybrid", Days: 10, LeaseMinutes: 30, GraceHours: 24} + body, _ := json.Marshal(issue) + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, ts.URL+"/api/v1/licenses", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer api-secret-0123456789-0123456789-ab") + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + var created LicenseView + if err := json.NewDecoder(resp.Body).Decode(&created); err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated || created.Token == "" { + t.Fatalf("issue API failed: %d %#v", resp.StatusCode, created) + } + validation, _ := json.Marshal(validateRequest{Token: created.Token, Product: "api-product", BaseURL: "https://customer.example.test"}) + resp, err = http.Post(ts.URL+"/api/v1/licenses/validate", "application/json", bytes.NewReader(validation)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(resp.Body) + t.Fatalf("validation API failed: %d %s", resp.StatusCode, payload) + } +} + +func TestResellerCustomerIsolation(t *testing.T) { + store, _ := OpenStore("") + server := testServer(t, store) + if err := server.generateKeys("issuer", "lease"); err != nil { + t.Fatal(err) + } + hash, _ := HashPassword("a-very-long-customer-password") + resellerA := User{ID: "reseller_a", Username: "reseller.a", DisplayName: "Reseller A", Role: RoleReseller, PasswordHash: hash, Active: true} + resellerB := User{ID: "reseller_b", Username: "reseller.b", DisplayName: "Reseller B", Role: RoleReseller, PasswordHash: hash, Active: true} + customer := User{ID: "customer_a", Username: "customer.a", DisplayName: "Customer A", Role: RoleCustomer, ParentUserID: resellerA.ID, PasswordHash: hash, Active: true} + for _, user := range []User{resellerA, resellerB, customer} { + if err := store.CreateUser(user); err != nil { + t.Fatal(err) + } + } + input := IssueInput{CustomerUserID: customer.ID, Customer: customer.DisplayName, Product: "product", Edition: "pro", Domains: []string{"*"}, Mode: "hybrid", Days: 30, LeaseMinutes: 60, GraceHours: 24} + if _, _, err := server.issueLicense(resellerB, input); err == nil { + t.Fatal("unassigned reseller issued a license for another reseller's customer") + } + record, _, err := server.issueLicense(resellerA, input) + if err != nil { + t.Fatal(err) + } + if got := store.ListLicensesFor(resellerB); len(got) != 0 { + t.Fatalf("reseller B can see %d foreign licenses", len(got)) + } + if got := store.ListLicensesFor(customer); len(got) != 1 || got[0].LicenseID != record.LicenseID { + t.Fatalf("customer portal did not receive its license: %#v", got) + } +} + +func TestLegacyRegistrationCompatibility(t *testing.T) { + store, _ := OpenStore("") + server := testServer(t, store) + if err := server.generateKeys("issuer", "lease"); err != nil { + t.Fatal(err) + } + keys, _ := store.KeySet() + encoded, err := server.vault.Decrypt(keys.IssuerPrivateCipher) + if err != nil { + t.Fatal(err) + } + privateKey, err := licensekit.DecodePrivateKey(encoded) + if err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + token, err := licensekit.SignLicense(privateKey, keys.IssuerKeyID, licensekit.Claims{Version: 1, LicenseID: "legacy-license", Issuer: "legacy", Customer: "Legacy Customer", Product: "legacy-product", Edition: "pro", Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: licensekit.ModeHybrid, ServerURL: "https://licenses.example.test"}}) + if err != nil { + t.Fatal(err) + } + ts := httptest.NewServer(server.Handler()) + defer ts.Close() + body, _ := json.Marshal(map[string]string{"token": token}) + req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/admin/licenses", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer api-secret-0123456789-0123456789-ab") + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("legacy register status %d", resp.StatusCode) + } + if _, ok := store.GetLicense("legacy-license"); !ok { + t.Fatal("legacy license was not imported") + } +} diff --git a/internal/platform/ratelimit.go b/internal/platform/ratelimit.go new file mode 100644 index 0000000..b98dae1 --- /dev/null +++ b/internal/platform/ratelimit.go @@ -0,0 +1,45 @@ +package platform + +import ( + "sync" + "time" +) + +type attempt struct { + count int + reset time.Time +} + +type limiter struct { + mu sync.Mutex + entries map[string]attempt + limit int + window time.Duration +} + +func newLimiter(limit int, window time.Duration) *limiter { + return &limiter{entries: map[string]attempt{}, limit: limit, window: window} +} + +func (l *limiter) Allow(key string) bool { + now := time.Now() + l.mu.Lock() + defer l.mu.Unlock() + entry := l.entries[key] + if entry.reset.IsZero() || now.After(entry.reset) { + l.entries[key] = attempt{count: 1, reset: now.Add(l.window)} + return true + } + if entry.count >= l.limit { + return false + } + entry.count++ + l.entries[key] = entry + return true +} + +func (l *limiter) Reset(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.entries, key) +} diff --git a/internal/platform/security.go b/internal/platform/security.go new file mode 100644 index 0000000..3aeaa79 --- /dev/null +++ b/internal/platform/security.go @@ -0,0 +1,157 @@ +package platform + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strconv" + "strings" +) + +const passwordIterations = 210_000 + +type Vault struct { + key []byte +} + +func NewVault(encoded string) (*Vault, error) { + encoded = strings.TrimSpace(encoded) + if encoded == "" { + return nil, errors.New("LICENSE_MASTER_KEY is required") + } + key, err := base64.RawStdEncoding.DecodeString(encoded) + if err != nil { + key, err = base64.StdEncoding.DecodeString(encoded) + } + if err != nil || len(key) != 32 { + return nil, errors.New("LICENSE_MASTER_KEY must be a base64-encoded 32-byte key") + } + return &Vault{key: key}, nil +} + +func (v *Vault) Encrypt(plaintext string) (string, error) { + block, err := aes.NewCipher(v.key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return "", err + } + sealed := gcm.Seal(nil, nonce, []byte(plaintext), nil) + payload := append(nonce, sealed...) + return "v1." + base64.RawURLEncoding.EncodeToString(payload), nil +} + +func (v *Vault) Decrypt(ciphertext string) (string, error) { + parts := strings.SplitN(strings.TrimSpace(ciphertext), ".", 2) + if len(parts) != 2 || parts[0] != "v1" { + return "", errors.New("unsupported encrypted value") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", err + } + block, err := aes.NewCipher(v.key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + if len(payload) < gcm.NonceSize() { + return "", errors.New("encrypted value is truncated") + } + plain, err := gcm.Open(nil, payload[:gcm.NonceSize()], payload[gcm.NonceSize():], nil) + if err != nil { + return "", errors.New("encrypted value authentication failed") + } + return string(plain), nil +} + +func HashPassword(password string) (string, error) { + if len(password) < 12 { + return "", errors.New("password must contain at least 12 characters") + } + if len(password) > 1024 { + return "", errors.New("password is too long") + } + salt := make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return "", err + } + dk := pbkdf2SHA256([]byte(password), salt, passwordIterations, 32) + return fmt.Sprintf("pbkdf2-sha256$%d$%s$%s", passwordIterations, base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(dk)), nil +} + +func VerifyPassword(encoded, password string) bool { + parts := strings.Split(encoded, "$") + if len(parts) != 4 || parts[0] != "pbkdf2-sha256" { + return false + } + iterations, err := strconv.Atoi(parts[1]) + if err != nil || iterations < 100_000 || iterations > 2_000_000 { + return false + } + salt, err1 := base64.RawStdEncoding.DecodeString(parts[2]) + expected, err2 := base64.RawStdEncoding.DecodeString(parts[3]) + if err1 != nil || err2 != nil || len(expected) == 0 { + return false + } + actual := pbkdf2SHA256([]byte(password), salt, iterations, len(expected)) + return subtle.ConstantTimeCompare(actual, expected) == 1 +} + +func pbkdf2SHA256(password, salt []byte, iterations, keyLen int) []byte { + hLen := sha256.Size + blocks := (keyLen + hLen - 1) / hLen + out := make([]byte, 0, blocks*hLen) + for i := 1; i <= blocks; i++ { + mac := hmac.New(sha256.New, password) + mac.Write(salt) + mac.Write([]byte{byte(i >> 24), byte(i >> 16), byte(i >> 8), byte(i)}) + u := mac.Sum(nil) + t := append([]byte(nil), u...) + for j := 1; j < iterations; j++ { + mac = hmac.New(sha256.New, password) + mac.Write(u) + u = mac.Sum(nil) + for k := range t { + t[k] ^= u[k] + } + } + out = append(out, t...) + } + return out[:keyLen] +} + +func randomToken(bytes int) (string, error) { + buf := make([]byte, bytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(buf), nil +} + +func tokenHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func constantTokenEqual(expected, actual string) bool { + if expected == "" || actual == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(tokenHash(expected)), []byte(tokenHash(actual))) == 1 +} diff --git a/internal/platform/server.go b/internal/platform/server.go new file mode 100644 index 0000000..bd6ad28 --- /dev/null +++ b/internal/platform/server.go @@ -0,0 +1,705 @@ +package platform + +import ( + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "io/fs" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + webassets "github.com/b1tsblog/license-platform/web" +) + +type Server struct { + cfg Config + store *Store + vault *Vault + logger *slog.Logger + mux *http.ServeMux + templates *template.Template + loginLimiter *limiter + apiLimiter *limiter +} + +type loginData struct { + Brand string + CSRF string + Error string +} + +type portalData struct { + Brand, PublicURL, PortalTitle, RoleLabel, Headline, Subline string + User User + CSRF, Message, Error string + KeysReady bool + Keys *KeySet + TrustStoreJSON string + Users, Customers, Resellers []User + Licenses []LicenseRecord + Audit []AuditEvent + ActiveCount, ExpiringCount, RevokedCount int +} + +type tokenData struct { + Brand string + License LicenseRecord + Token string +} + +func New(cfg Config, store *Store, logger *slog.Logger) (*Server, error) { + if err := cfg.normalize(); err != nil { + return nil, err + } + if store == nil { + return nil, errors.New("store is required") + } + vault, err := NewVault(cfg.MasterKey) + if err != nil { + return nil, err + } + if logger == nil { + logger = slog.Default() + } + funcs := template.FuncMap{ + "formatTime": func(value int64) string { + if value <= 0 { + return "–" + } + return time.Unix(value, 0).UTC().Format("02.01.2006 · 15:04 UTC") + }, + "initial": func(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "?" + } + return strings.ToUpper(string([]rune(value)[0])) + }, + } + templates, err := template.New("portal").Funcs(funcs).ParseFS(webassets.FS, "templates/*.html") + if err != nil { + return nil, err + } + s := &Server{cfg: cfg, store: store, vault: vault, logger: logger, mux: http.NewServeMux(), templates: templates, loginLimiter: newLimiter(8, 15*time.Minute), apiLimiter: newLimiter(180, time.Minute)} + if err := s.bootstrap(); err != nil { + return nil, err + } + s.routes() + return s, nil +} + +func (s *Server) Handler() http.Handler { + return s.securityHeaders(s.requestSizeLimit(s.requestLog(s.mux))) +} + +func (s *Server) bootstrap() error { + users := s.store.ListUsers() + if len(users) > 0 { + return nil + } + if strings.TrimSpace(s.cfg.BootstrapUsername) == "" || strings.TrimSpace(s.cfg.BootstrapPassword) == "" { + return errors.New("bootstrap admin credentials are required for an empty data store") + } + hash, err := HashPassword(s.cfg.BootstrapPassword) + if err != nil { + return fmt.Errorf("bootstrap password: %w", err) + } + created, err := s.store.EnsureBootstrapAdmin(s.cfg.BootstrapUsername, s.cfg.BootstrapName, hash) + if err == nil && created { + s.logger.Warn("bootstrap administrator created", "username", normalizeUsername(s.cfg.BootstrapUsername)) + } + return err +} + +func (s *Server) routes() { + staticFS, _ := fs.Sub(webassets.FS, "static") + s.mux.Handle("GET /assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(staticFS)))) + s.mux.HandleFunc("GET /healthz", s.health) + s.mux.HandleFunc("GET /.well-known/license-server", s.discovery) + s.mux.HandleFunc("GET /api/v1/trust-store", s.publicTrustStore) + s.mux.HandleFunc("GET /login", s.loginPage) + s.mux.HandleFunc("POST /login", s.login) + s.mux.HandleFunc("POST /logout", s.logout) + s.mux.HandleFunc("POST /account/password", s.changePassword) + s.mux.HandleFunc("GET /", s.portal) + s.mux.HandleFunc("POST /admin/keys/generate", s.generateKeysForm) + s.mux.HandleFunc("POST /admin/keys/import", s.importKeysForm) + s.mux.HandleFunc("POST /admin/users", s.createUserForm) + s.mux.HandleFunc("POST /reseller/customers", s.createCustomerForm) + s.mux.HandleFunc("POST /licenses", s.issueForm) + s.mux.HandleFunc("GET /licenses/{id}/token", s.tokenPage) + s.mux.HandleFunc("POST /licenses/{id}/revoke", s.revokeForm) + s.mux.HandleFunc("POST /licenses/{id}/restore", s.restoreForm) + + s.mux.HandleFunc("POST /api/v1/licenses/validate", s.validateAPI) + s.mux.HandleFunc("POST /v1/introspect", s.validateAPI) + s.mux.HandleFunc("GET /api/v1/licenses", s.listAPI) + s.mux.HandleFunc("POST /api/v1/licenses", s.issueAPI) + s.mux.HandleFunc("POST /api/v1/licenses/import", s.importAPI) + s.mux.HandleFunc("POST /api/v1/licenses/{id}/revoke", s.revokeAPI) + s.mux.HandleFunc("POST /api/v1/licenses/{id}/restore", s.restoreAPI) + + // Compatibility with the original reference server and licenseweb. + s.mux.HandleFunc("GET /v1/admin/licenses", s.listAPI) + s.mux.HandleFunc("POST /v1/admin/licenses", s.legacyRegisterAPI) + s.mux.HandleFunc("POST /v1/admin/licenses/{id}/revoke", s.revokeAPI) + s.mux.HandleFunc("POST /v1/admin/licenses/{id}/restore", s.restoreAPI) +} + +func (s *Server) health(w http.ResponseWriter, _ *http.Request) { + _, ready := s.store.KeySet() + s.writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "keysReady": ready, "version": "1.0.0"}) +} + +func (s *Server) discovery(w http.ResponseWriter, _ *http.Request) { + s.writeJSON(w, http.StatusOK, map[string]any{"issuer": s.cfg.Brand, "serverUrl": s.cfg.PublicURL, "validationEndpoint": s.cfg.PublicURL + "/api/v1/licenses/validate", "trustStoreEndpoint": s.cfg.PublicURL + "/api/v1/trust-store"}) +} + +func (s *Server) publicTrustStore(w http.ResponseWriter, _ *http.Request) { + store, err := s.trustStore() + if err != nil { + s.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": err.Error()}) + return + } + s.writeJSON(w, http.StatusOK, store) +} + +func (s *Server) loginPage(w http.ResponseWriter, r *http.Request) { + if _, _, ok := s.currentUser(r); ok { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + s.render(w, "login.html", loginData{Brand: s.cfg.Brand, CSRF: s.loginCSRF(w, r), Error: r.URL.Query().Get("error")}) +} + +func (s *Server) login(w http.ResponseWriter, r *http.Request) { + if !s.loginLimiter.Allow(remoteIP(r)) { + http.Error(w, "too many login attempts", http.StatusTooManyRequests) + return + } + if err := r.ParseForm(); err != nil || !s.checkLoginCSRF(r) { + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + username := normalizeUsername(r.FormValue("username")) + password := r.FormValue("password") + if len(username) > 200 || len(password) > 1024 { + http.Redirect(w, r, "/login?error=Anmeldung+fehlgeschlagen", http.StatusSeeOther) + return + } + user, ok := s.store.FindUserByUsername(username) + if !ok || !user.Active || !VerifyPassword(user.PasswordHash, password) { + _ = s.audit(User{}, "login.failed", username, "invalid credentials", r) + http.Redirect(w, r, "/login?error=Anmeldung+fehlgeschlagen", http.StatusSeeOther) + return + } + raw, session, err := s.newSession(user) + if err != nil { + http.Error(w, "session creation failed", http.StatusInternalServerError) + return + } + s.loginLimiter.Reset(remoteIP(r)) + s.setSessionCookie(w, raw, time.Unix(session.ExpiresAt, 0)) + _ = s.audit(user, "login.succeeded", user.ID, "portal session created", r) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) logout(w http.ResponseWriter, r *http.Request) { + user, session, ok := s.requireUser(w, r) + if !ok { + return + } + if !s.verifyCSRF(r, session) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + _ = s.store.DeleteSession(session.IDHash) + s.clearSessionCookie(w) + _ = s.audit(user, "logout", user.ID, "session closed", r) + http.Redirect(w, r, "/login", http.StatusSeeOther) +} + +func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { + user, session, ok := s.requireUser(w, r) + if !ok { + return + } + if !s.verifyCSRF(r, session) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + if !VerifyPassword(user.PasswordHash, r.FormValue("currentPassword")) { + _ = s.audit(user, "password.change.failed", user.ID, "current password mismatch", r) + http.Redirect(w, r, "/?error=Aktuelles+Passwort+ist+falsch", http.StatusSeeOther) + return + } + hash, err := HashPassword(r.FormValue("newPassword")) + if err != nil { + http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther) + return + } + if err := s.store.UpdatePassword(user.ID, hash); err != nil { + http.Error(w, "password update failed", http.StatusInternalServerError) + return + } + _ = s.store.DeleteSessionsForUser(user.ID) + s.clearSessionCookie(w) + _ = s.audit(user, "password.changed", user.ID, "all sessions invalidated", r) + http.Redirect(w, r, "/login?error=Passwort+geändert.+Bitte+neu+anmelden", http.StatusSeeOther) +} + +func (s *Server) portal(w http.ResponseWriter, r *http.Request) { + user, session, ok := s.requireUser(w, r) + if !ok { + return + } + licenses := s.store.ListLicensesFor(user) + keys, keysReady := s.store.KeySet() + data := portalData{Brand: s.cfg.Brand, PublicURL: s.cfg.PublicURL, User: user, CSRF: session.CSRFToken, KeysReady: keysReady, Keys: keys, Licenses: licenses, Message: r.URL.Query().Get("message"), Error: r.URL.Query().Get("error")} + data.PortalTitle, data.RoleLabel, data.Headline, data.Subline = roleCopy(user.Role) + for _, record := range licenses { + if record.Revoked { + data.RevokedCount++ + } else { + data.ActiveCount++ + } + if !record.Revoked && record.ExpiresAt <= time.Now().UTC().Add(30*24*time.Hour).Unix() { + data.ExpiringCount++ + } + } + if user.Role == RoleAdmin { + data.Users = s.store.ListUsers() + data.Audit = s.store.ListAudit(50) + } + for _, candidate := range s.store.ListUsers() { + if candidate.Role == RoleReseller && candidate.Active && user.Role == RoleAdmin { + data.Resellers = append(data.Resellers, candidate) + } + if candidate.Role == RoleCustomer && candidate.Active { + if user.Role == RoleAdmin || (user.Role == RoleReseller && candidate.ParentUserID == user.ID) { + data.Customers = append(data.Customers, candidate) + } + } + } + if keysReady { + if trust, err := s.trustStore(); err == nil { + data.TrustStoreJSON = trustStoreJSON(trust) + } + } + s.render(w, "portal.html", data) +} + +func roleCopy(role Role) (string, string, string, string) { + switch role { + case RoleAdmin: + return "Admin / Management", "Management", "Steuere Vertrauen, Zugänge und Lizenzbestand.", "Zentrale Kontrolle über Signaturschlüssel, Nutzer, Aussteller und den vollständigen Audit-Trail." + case RoleReseller: + return "Reseller / Autor", "Reseller", "Lizenzen ausstellen, ohne die Root Keys zu sehen.", "Erstelle und verwalte ausschließlich die von dir verantworteten Kundenlizenzen." + default: + return "Kunde / Client", "Kunde", "Deine Lizenzen. Transparent und jederzeit verfügbar.", "Prüfe Laufzeit, Status, Features und sichere Client-Konfigurationen für deine Produkte." + } +} + +func (s *Server) generateKeysForm(w http.ResponseWriter, r *http.Request) { + user, session, ok := s.requireRole(w, r, RoleAdmin) + if !ok { + return + } + if !s.verifyCSRF(r, session) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + err := s.generateKeys(r.FormValue("issuerKeyId"), r.FormValue("leaseKeyId")) + if err != nil { + _ = s.audit(user, "keys.generate.denied", "keyset", err.Error(), r) + http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther) + return + } + _ = s.audit(user, "keys.generated", "keyset", "issuer and lease key pairs initialized", r) + http.Redirect(w, r, "/?message=Schlüsselpaare+wurden+erzeugt+und+gesperrt", http.StatusSeeOther) +} + +func (s *Server) importKeysForm(w http.ResponseWriter, r *http.Request) { + user, session, ok := s.requireRole(w, r, RoleAdmin) + if !ok { + return + } + if !s.verifyCSRF(r, session) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + err := s.importKeys(r.FormValue("issuerKeyId"), r.FormValue("issuerPrivate"), r.FormValue("leaseKeyId"), r.FormValue("leasePrivate")) + if err != nil { + _ = s.audit(user, "keys.import.denied", "keyset", err.Error(), r) + http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther) + return + } + _ = s.audit(user, "keys.imported", "keyset", "existing issuer and lease keys imported and locked", r) + http.Redirect(w, r, "/?message=Bestehende+Schlüsselpaare+verschlüsselt+importiert+und+gesperrt", http.StatusSeeOther) +} + +func (s *Server) createUserForm(w http.ResponseWriter, r *http.Request) { + actor, session, ok := s.requireRole(w, r, RoleAdmin) + if !ok { + return + } + if !s.verifyCSRF(r, session) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + role := Role(strings.TrimSpace(r.FormValue("role"))) + if role != RoleAdmin && role != RoleReseller && role != RoleCustomer { + http.Redirect(w, r, "/?error=Ungültige+Rolle", http.StatusSeeOther) + return + } + hash, err := HashPassword(r.FormValue("password")) + if err != nil { + http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther) + return + } + parentUserID := "" + if role == RoleCustomer { + parentUserID = strings.TrimSpace(r.FormValue("resellerUserId")) + if parentUserID != "" { + parent, ok := s.store.GetUser(parentUserID) + if !ok || parent.Role != RoleReseller || !parent.Active { + http.Redirect(w, r, "/?error=Zugeordneter+Reseller+ist+ungültig", http.StatusSeeOther) + return + } + } + } + user := User{ID: newID("usr"), Username: r.FormValue("username"), DisplayName: strings.TrimSpace(r.FormValue("displayName")), Role: role, ParentUserID: parentUserID, PasswordHash: hash, Active: true} + if user.DisplayName == "" { + user.DisplayName = user.Username + } + if err := s.store.CreateUser(user); err != nil { + http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther) + return + } + _ = s.audit(actor, "user.created", user.ID, string(role)+":"+user.Username, r) + http.Redirect(w, r, "/?message=Benutzer+angelegt", http.StatusSeeOther) +} + +func (s *Server) createCustomerForm(w http.ResponseWriter, r *http.Request) { + actor, session, ok := s.requireRole(w, r, RoleReseller) + if !ok { + return + } + if !s.verifyCSRF(r, session) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + hash, err := HashPassword(r.FormValue("password")) + if err != nil { + http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther) + return + } + user := User{ID: newID("usr"), Username: r.FormValue("username"), DisplayName: strings.TrimSpace(r.FormValue("displayName")), Role: RoleCustomer, ParentUserID: actor.ID, PasswordHash: hash, Active: true} + if user.DisplayName == "" { + user.DisplayName = user.Username + } + if err := s.store.CreateUser(user); err != nil { + http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther) + return + } + _ = s.audit(actor, "customer.created", user.ID, user.Username, r) + http.Redirect(w, r, "/?message=Kundenportal+angelegt", http.StatusSeeOther) +} + +func (s *Server) issueForm(w http.ResponseWriter, r *http.Request) { + actor, session, ok := s.requireRole(w, r, RoleAdmin, RoleReseller) + if !ok { + return + } + if !s.verifyCSRF(r, session) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + input, err := parseIssueForm(r.Form) + if err == nil { + _, _, err = s.issueLicense(actor, input) + } + if err != nil { + http.Redirect(w, r, "/?error="+queryValue(err.Error()), http.StatusSeeOther) + return + } + _ = s.audit(actor, "license.issued", input.Product, input.Customer+" / "+input.Edition, r) + http.Redirect(w, r, "/?message=Lizenz+signiert+und+registriert", http.StatusSeeOther) +} + +func (s *Server) tokenPage(w http.ResponseWriter, r *http.Request) { + user, _, ok := s.requireUser(w, r) + if !ok { + return + } + record, ok := s.store.GetLicense(r.PathValue("id")) + if !ok || !canAccessLicense(user, record) { + http.Error(w, "not found", http.StatusNotFound) + return + } + token, err := s.tokenFor(record) + if err != nil { + http.Error(w, "token unavailable", http.StatusInternalServerError) + return + } + s.render(w, "token.html", tokenData{Brand: s.cfg.Brand, License: record, Token: token}) +} + +func (s *Server) revokeForm(w http.ResponseWriter, r *http.Request) { s.mutateLicenseForm(w, r, true) } +func (s *Server) restoreForm(w http.ResponseWriter, r *http.Request) { + s.mutateLicenseForm(w, r, false) +} + +func (s *Server) mutateLicenseForm(w http.ResponseWriter, r *http.Request, revoked bool) { + actor, session, ok := s.requireRole(w, r, RoleAdmin, RoleReseller) + if !ok { + return + } + if !s.verifyCSRF(r, session) { + http.Error(w, "invalid CSRF token", http.StatusForbidden) + return + } + record, ok := s.store.GetLicense(r.PathValue("id")) + if !ok || !canManageLicense(actor, record) { + http.Error(w, "not found", http.StatusNotFound) + return + } + reason := "manually revoked" + if !revoked { + reason = "" + } + if err := s.store.SetRevoked(record.LicenseID, revoked, reason); err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + action := "license.restored" + if revoked { + action = "license.revoked" + } + _ = s.audit(actor, action, record.LicenseID, reason, r) + http.Redirect(w, r, "/?message=Lizenzstatus+aktualisiert", http.StatusSeeOther) +} + +func (s *Server) validateAPI(w http.ResponseWriter, r *http.Request) { + if !s.apiLimiter.Allow(remoteIP(r)) { + s.writeJSON(w, http.StatusTooManyRequests, validateResponse{Reason: "rate limit exceeded"}) + return + } + var request validateRequest + if err := decodeJSON(r, &request); err != nil { + s.writeJSON(w, http.StatusBadRequest, validateResponse{Reason: err.Error()}) + return + } + response, err := s.validateLicense(request) + if err != nil { + s.writeJSON(w, http.StatusForbidden, response) + return + } + s.writeJSON(w, http.StatusOK, response) +} + +func (s *Server) listAPI(w http.ResponseWriter, r *http.Request) { + actor, err := s.apiActor(r) + if err != nil { + s.apiUnauthorized(w) + return + } + records := s.store.ListLicensesFor(actor) + views := make([]LicenseView, 0, len(records)) + for _, record := range records { + views = append(views, licenseView(record, "")) + } + s.writeJSON(w, http.StatusOK, map[string]any{"licenses": views}) +} + +func (s *Server) issueAPI(w http.ResponseWriter, r *http.Request) { + actor, err := s.apiActor(r) + if err != nil { + s.apiUnauthorized(w) + return + } + var input IssueInput + if err := decodeJSON(r, &input); err != nil { + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + record, token, err := s.issueLicense(actor, input) + if err != nil { + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + _ = s.audit(actor, "license.issued.api", record.LicenseID, record.Product, r) + s.writeJSON(w, http.StatusCreated, licenseView(record, token)) +} + +func (s *Server) importAPI(w http.ResponseWriter, r *http.Request) { + actor, err := s.apiActor(r) + if err != nil { + s.apiUnauthorized(w) + return + } + var payload struct { + Token string `json:"token"` + CustomerUserID string `json:"customerUserId,omitempty"` + } + if err := decodeJSON(r, &payload); err != nil { + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + record, err := s.importLicense(actor, payload.Token, strings.TrimSpace(payload.CustomerUserID)) + if err != nil { + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + _ = s.audit(actor, "license.imported.api", record.LicenseID, record.Product, r) + s.writeJSON(w, http.StatusCreated, licenseView(record, "")) +} + +func (s *Server) legacyRegisterAPI(w http.ResponseWriter, r *http.Request) { + actor, err := s.apiActor(r) + if err != nil { + s.apiUnauthorized(w) + return + } + var payload struct { + Token string `json:"token"` + } + if err := decodeJSON(r, &payload); err != nil { + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + record, err := s.importLicense(actor, payload.Token, "") + if err != nil { + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + _ = s.audit(actor, "license.registered.legacy", record.LicenseID, record.Product, r) + s.writeJSON(w, http.StatusCreated, licenseView(record, "")) +} + +func (s *Server) revokeAPI(w http.ResponseWriter, r *http.Request) { s.mutateLicenseAPI(w, r, true) } +func (s *Server) restoreAPI(w http.ResponseWriter, r *http.Request) { s.mutateLicenseAPI(w, r, false) } + +func (s *Server) mutateLicenseAPI(w http.ResponseWriter, r *http.Request, revoked bool) { + actor, err := s.apiActor(r) + if err != nil { + s.apiUnauthorized(w) + return + } + record, ok := s.store.GetLicense(r.PathValue("id")) + if !ok || !canManageLicense(actor, record) { + s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "license not found"}) + return + } + reason := "" + if revoked { + var payload struct { + Reason string `json:"reason"` + } + _ = decodeJSONAllowEmpty(r, &payload) + reason = strings.TrimSpace(payload.Reason) + if reason == "" { + reason = "revoked via API" + } + } + if err := s.store.SetRevoked(record.LicenseID, revoked, reason); err != nil { + s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + action := "license.restored.api" + if revoked { + action = "license.revoked.api" + } + _ = s.audit(actor, action, record.LicenseID, reason, r) + s.writeJSON(w, http.StatusOK, map[string]any{"licenseId": record.LicenseID, "revoked": revoked}) +} + +func canAccessLicense(user User, record LicenseRecord) bool { + return user.Role == RoleAdmin || (user.Role == RoleReseller && record.IssuedByUserID == user.ID) || (user.Role == RoleCustomer && record.CustomerUserID == user.ID) +} + +func canManageLicense(user User, record LicenseRecord) bool { + return user.Role == RoleAdmin || (user.Role == RoleReseller && record.IssuedByUserID == user.ID) +} + +func (s *Server) audit(actor User, action, target, detail string, r *http.Request) error { + event := AuditEvent{ID: newID("evt"), ActorID: actor.ID, Action: action, Target: target, Detail: detail, RemoteIP: remoteIP(r), CreatedAt: unixNow()} + return s.store.AddAudit(event) +} + +func (s *Server) render(w http.ResponseWriter, name string, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.templates.ExecuteTemplate(w, name, data); err != nil { + s.logger.Error("render template", "name", name, "error", err) + } +} + +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 (s *Server) apiUnauthorized(w http.ResponseWriter) { + w.Header().Set("WWW-Authenticate", "Bearer") + s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) +} + +func decodeJSON(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 + } + if dec.Decode(&struct{}{}) != io.EOF { + return errors.New("request body must contain one JSON object") + } + return nil +} + +func decodeJSONAllowEmpty(r *http.Request, target any) error { + err := decodeJSON(r, target) + if errors.Is(err, io.EOF) { + return nil + } + return err +} + +func queryValue(value string) string { return url.QueryEscape(value) } + +func (s *Server) requestSizeLimit(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, 1<<20) + } + next.ServeHTTP(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") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + w.Header().Set("Content-Security-Policy", "default-src 'self'; style-src 'self'; img-src 'self' data:; script-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + w.Header().Set("Cache-Control", "no-store") + if s.cfg.SecureCookies { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) requestLog(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + started := time.Now() + next.ServeHTTP(w, r) + s.logger.Info("http request", "method", r.Method, "path", r.URL.Path, "remote_ip", remoteIP(r), "duration_ms", time.Since(started).Milliseconds()) + }) +} diff --git a/internal/platform/store.go b/internal/platform/store.go new file mode 100644 index 0000000..14d0a85 --- /dev/null +++ b/internal/platform/store.go @@ -0,0 +1,353 @@ +package platform + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +var ( + ErrNotFound = errors.New("not found") + ErrConflict = errors.New("already exists") + ErrKeysLocked = errors.New("key pairs already exist and cannot be overwritten") +) + +type Store struct { + mu sync.RWMutex + path string + doc document +} + +func OpenStore(path string) (*Store, error) { + s := &Store{path: path, doc: document{Version: 1, Users: map[string]User{}, Licenses: map[string]LicenseRecord{}, Sessions: map[string]Session{}, Audit: []AuditEvent{}}} + if strings.TrimSpace(path) == "" { + return s, nil + } + data, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(data, &s.doc); err != nil { + return nil, err + } + if s.doc.Version != 1 { + return nil, errors.New("unsupported data-store version") + } + if s.doc.Users == nil { + s.doc.Users = map[string]User{} + } + if s.doc.Licenses == nil { + s.doc.Licenses = map[string]LicenseRecord{} + } + if s.doc.Sessions == nil { + s.doc.Sessions = map[string]Session{} + } + return s, nil +} + +func (s *Store) KeySet() (*KeySet, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + if s.doc.Keys == nil { + return nil, false + } + copy := *s.doc.Keys + return ©, true +} + +func (s *Store) SetKeysOnce(keys KeySet) error { + s.mu.Lock() + defer s.mu.Unlock() + if s.doc.Keys != nil { + return ErrKeysLocked + } + keys.CreatedAt = unixNow() + s.doc.Keys = &keys + return s.persistLocked() +} + +func (s *Store) CreateUser(user User) error { + s.mu.Lock() + defer s.mu.Unlock() + username := normalizeUsername(user.Username) + if !validUsername(username) { + return errors.New("username must be 3-120 characters and contain only letters, numbers, dot, dash, underscore or @") + } + if len(strings.TrimSpace(user.DisplayName)) > 200 { + return errors.New("display name is too long") + } + for _, existing := range s.doc.Users { + if normalizeUsername(existing.Username) == username { + return ErrConflict + } + } + now := unixNow() + user.Username = username + user.CreatedAt = now + user.UpdatedAt = now + if !user.Active { + user.Active = true + } + s.doc.Users[user.ID] = user + return s.persistLocked() +} + +func (s *Store) EnsureBootstrapAdmin(username, displayName, passwordHash string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, user := range s.doc.Users { + if user.Role == RoleAdmin { + return false, nil + } + } + username = normalizeUsername(username) + if !validUsername(username) { + return false, errors.New("bootstrap username is invalid") + } + now := unixNow() + user := User{ID: "usr_admin_bootstrap", Username: username, DisplayName: strings.TrimSpace(displayName), Role: RoleAdmin, PasswordHash: passwordHash, Active: true, CreatedAt: now, UpdatedAt: now} + if user.DisplayName == "" { + user.DisplayName = "Administrator" + } + s.doc.Users[user.ID] = user + return true, s.persistLocked() +} + +func (s *Store) FindUserByUsername(username string) (User, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + username = normalizeUsername(username) + for _, user := range s.doc.Users { + if normalizeUsername(user.Username) == username { + return user, true + } + } + return User{}, false +} + +func (s *Store) GetUser(id string) (User, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + user, ok := s.doc.Users[id] + return user, ok +} + +func (s *Store) UpdatePassword(userID, passwordHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + user, ok := s.doc.Users[userID] + if !ok { + return ErrNotFound + } + user.PasswordHash = passwordHash + user.UpdatedAt = unixNow() + s.doc.Users[userID] = user + return s.persistLocked() +} + +func (s *Store) ListUsers() []User { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]User, 0, len(s.doc.Users)) + for _, user := range s.doc.Users { + out = append(out, user) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Role == out[j].Role { + return out[i].Username < out[j].Username + } + return out[i].Role < out[j].Role + }) + return out +} + +func (s *Store) PutLicense(record LicenseRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + now := unixNow() + if existing, ok := s.doc.Licenses[record.LicenseID]; ok { + record.CreatedAt = existing.CreatedAt + } + if record.CreatedAt == 0 { + record.CreatedAt = now + } + record.UpdatedAt = now + s.doc.Licenses[record.LicenseID] = record + return s.persistLocked() +} + +func (s *Store) GetLicense(id string) (LicenseRecord, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + record, ok := s.doc.Licenses[id] + return cloneLicense(record), ok +} + +func (s *Store) ListLicensesFor(user User) []LicenseRecord { + s.mu.RLock() + defer s.mu.RUnlock() + out := make([]LicenseRecord, 0, len(s.doc.Licenses)) + for _, record := range s.doc.Licenses { + switch user.Role { + case RoleAdmin: + out = append(out, cloneLicense(record)) + case RoleReseller: + if record.IssuedByUserID == user.ID { + out = append(out, cloneLicense(record)) + } + case RoleCustomer: + if record.CustomerUserID == user.ID { + out = append(out, cloneLicense(record)) + } + } + } + sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt > out[j].UpdatedAt }) + return out +} + +func (s *Store) SetRevoked(id string, revoked bool, reason string) error { + s.mu.Lock() + defer s.mu.Unlock() + record, ok := s.doc.Licenses[id] + if !ok { + return ErrNotFound + } + record.Revoked = revoked + if revoked { + record.RevocationReason = strings.TrimSpace(reason) + } else { + record.RevocationReason = "" + } + record.UpdatedAt = unixNow() + s.doc.Licenses[id] = record + return s.persistLocked() +} + +func (s *Store) CreateSession(session Session) error { + s.mu.Lock() + defer s.mu.Unlock() + s.cleanupSessionsLocked(time.Now().UTC()) + s.doc.Sessions[session.IDHash] = session + return s.persistLocked() +} + +func (s *Store) GetSession(idHash string, now time.Time) (Session, bool) { + s.mu.RLock() + defer s.mu.RUnlock() + session, ok := s.doc.Sessions[idHash] + if !ok || session.ExpiresAt <= now.Unix() { + return Session{}, false + } + return session, true +} + +func (s *Store) DeleteSessionsForUser(userID string) error { + s.mu.Lock() + defer s.mu.Unlock() + for id, session := range s.doc.Sessions { + if session.UserID == userID { + delete(s.doc.Sessions, id) + } + } + return s.persistLocked() +} + +func (s *Store) DeleteSession(idHash string) error { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.doc.Sessions, idHash) + return s.persistLocked() +} + +func (s *Store) AddAudit(event AuditEvent) error { + s.mu.Lock() + defer s.mu.Unlock() + if event.CreatedAt == 0 { + event.CreatedAt = unixNow() + } + s.doc.Audit = append(s.doc.Audit, event) + if len(s.doc.Audit) > 2000 { + s.doc.Audit = append([]AuditEvent(nil), s.doc.Audit[len(s.doc.Audit)-2000:]...) + } + return s.persistLocked() +} + +func (s *Store) ListAudit(limit int) []AuditEvent { + s.mu.RLock() + defer s.mu.RUnlock() + if limit <= 0 || limit > 200 { + limit = 50 + } + start := len(s.doc.Audit) - limit + if start < 0 { + start = 0 + } + out := append([]AuditEvent(nil), s.doc.Audit[start:]...) + sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt }) + return out +} + +func (s *Store) persistLocked() error { + if strings.TrimSpace(s.path) == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil && filepath.Dir(s.path) != "." { + return err + } + data, err := json.MarshalIndent(s.doc, "", " ") + if err != nil { + return err + } + temp := s.path + ".tmp" + if err := os.WriteFile(temp, data, 0o600); err != nil { + return err + } + return os.Rename(temp, s.path) +} + +func (s *Store) cleanupSessionsLocked(now time.Time) { + for key, session := range s.doc.Sessions { + if session.ExpiresAt <= now.Unix() { + delete(s.doc.Sessions, key) + } + } +} + +func normalizeUsername(value string) string { + return strings.ToLower(strings.TrimSpace(value)) +} + +func cloneLicense(record LicenseRecord) LicenseRecord { + record.Features = append([]string(nil), record.Features...) + record.Domains = append([]string(nil), record.Domains...) + record.InstanceIDs = append([]string(nil), record.InstanceIDs...) + if record.Limits != nil { + copy := make(map[string]int64, len(record.Limits)) + for key, value := range record.Limits { + copy[key] = value + } + record.Limits = copy + } + return record +} + +func validUsername(value string) bool { + if len(value) < 3 || len(value) > 120 { + return false + } + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '.' || r == '-' || r == '_' || r == '@' { + continue + } + return false + } + return true +} diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..61138f1 --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,252 @@ +openapi: 3.1.0 +info: + title: Universal License Platform API + version: 1.0.0 + description: Management and runtime validation API for signed product licenses. +servers: + - url: https://licenses.example.org +paths: + /.well-known/license-server: + get: + summary: Discover the license platform + responses: + '200': + description: Discovery document + content: + application/json: + schema: + $ref: '#/components/schemas/Discovery' + /api/v1/trust-store: + get: + summary: Read public issuer and lease verification keys + responses: + '200': + description: Public Ed25519 trust store + content: + application/json: + schema: + $ref: '#/components/schemas/TrustStore' + '503': + $ref: '#/components/responses/Error' + /api/v1/licenses/validate: + post: + summary: Validate a license and issue a short-lived signed lease + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationRequest' + responses: + '200': + description: Valid license and signed lease + content: + application/json: + schema: + $ref: '#/components/schemas/ValidationResponse' + '400': + $ref: '#/components/responses/Error' + '403': + $ref: '#/components/responses/Error' + '429': + $ref: '#/components/responses/Error' + /api/v1/licenses: + get: + security: [{bearerAuth: []}] + summary: List licenses visible to the management actor + responses: + '200': + description: License list without plaintext tokens + content: + application/json: + schema: + type: object + required: [licenses] + properties: + licenses: + type: array + items: {$ref: '#/components/schemas/License'} + '401': {$ref: '#/components/responses/Error'} + post: + security: [{bearerAuth: []}] + summary: Sign and register a license + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/IssueLicense' + responses: + '201': + description: Created license; plaintext token is returned only here + content: + application/json: + schema: + $ref: '#/components/schemas/License' + '400': {$ref: '#/components/responses/Error'} + '401': {$ref: '#/components/responses/Error'} + /api/v1/licenses/import: + post: + security: [{bearerAuth: []}] + summary: Register an already signed, still-valid license token + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [token] + properties: + token: {type: string, minLength: 20} + customerUserId: {type: string} + responses: + '201': + description: Imported license + content: + application/json: + schema: {$ref: '#/components/schemas/License'} + '400': {$ref: '#/components/responses/Error'} + '401': {$ref: '#/components/responses/Error'} + /api/v1/licenses/{licenseId}/revoke: + post: + security: [{bearerAuth: []}] + summary: Revoke a registered license + parameters: + - $ref: '#/components/parameters/LicenseId' + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + reason: {type: string, maxLength: 500} + responses: + '200': {$ref: '#/components/responses/Mutation'} + '401': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} + /api/v1/licenses/{licenseId}/restore: + post: + security: [{bearerAuth: []}] + summary: Restore a revoked license + parameters: + - $ref: '#/components/parameters/LicenseId' + responses: + '200': {$ref: '#/components/responses/Mutation'} + '401': {$ref: '#/components/responses/Error'} + '404': {$ref: '#/components/responses/Error'} +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + parameters: + LicenseId: + name: licenseId + in: path + required: true + schema: {type: string} + responses: + Error: + description: Error response + content: + application/json: + schema: + type: object + properties: + error: {type: string} + reason: {type: string} + Mutation: + description: License status changed + content: + application/json: + schema: + type: object + required: [licenseId, revoked] + properties: + licenseId: {type: string} + revoked: {type: boolean} + schemas: + Discovery: + type: object + required: [issuer, serverUrl, validationEndpoint, trustStoreEndpoint] + properties: + issuer: {type: string} + serverUrl: {type: string, format: uri} + validationEndpoint: {type: string, format: uri} + trustStoreEndpoint: {type: string, format: uri} + TrustStore: + type: object + required: [licenseKeys, leaseKeys] + properties: + licenseKeys: + type: object + additionalProperties: {type: string} + leaseKeys: + type: object + additionalProperties: {type: string} + ValidationRequest: + type: object + additionalProperties: false + required: [token, product, baseUrl] + properties: + token: {type: string, minLength: 20} + product: {type: string, minLength: 1} + baseUrl: {type: string, format: uri} + host: {type: string} + instanceId: {type: string} + clientVersion: {type: string} + ValidationResponse: + type: object + required: [valid] + properties: + valid: {type: boolean} + leaseToken: {type: string} + expiresAt: {type: string, format: date-time} + reason: {type: string} + IssueLicense: + type: object + additionalProperties: false + required: [customer, product, edition, mode, days] + properties: + customerUserId: {type: string} + customer: {type: string, minLength: 1} + product: {type: string, minLength: 1} + edition: {type: string, minLength: 1} + features: + type: array + items: {type: string} + uniqueItems: true + limits: + type: object + additionalProperties: {type: integer, minimum: 0} + domains: + type: array + items: {type: string} + uniqueItems: true + instanceIds: + type: array + items: {type: string} + uniqueItems: true + mode: {enum: [offline, hybrid, online]} + days: {type: integer, minimum: 1, maximum: 3650} + leaseMinutes: {type: integer, minimum: 1, maximum: 1440, default: 60} + graceHours: {type: integer, minimum: 0, maximum: 8760, default: 72} + License: + allOf: + - $ref: '#/components/schemas/IssueLicense' + - type: object + required: [licenseId, issuer, serverUrl, expiresAt, revoked, createdAt, updatedAt] + properties: + licenseId: {type: string} + issuer: {type: string} + serverUrl: {type: string, format: uri} + expiresAt: {type: integer} + revoked: {type: boolean} + reason: {type: string} + token: + type: string + description: Present only in create and explicitly authorized token-delivery responses. + createdAt: {type: integer} + updatedAt: {type: integer} diff --git a/pkg/licensekit/doc.go b/pkg/licensekit/doc.go new file mode 100644 index 0000000..081cd08 --- /dev/null +++ b/pkg/licensekit/doc.go @@ -0,0 +1,3 @@ +// Package licensekit implements product-neutral Ed25519 licence and lease +// tokens, embedded trust stores, context validation and key rotation by key ID. +package licensekit diff --git a/pkg/licensekit/licensekit.go b/pkg/licensekit/licensekit.go new file mode 100644 index 0000000..546989d --- /dev/null +++ b/pkg/licensekit/licensekit.go @@ -0,0 +1,479 @@ +package licensekit + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/url" + "sort" + "strings" + "time" +) + +const ( + TokenTypeLicense = "LICENSE" + TokenTypeLease = "LEASE" + AlgorithmEdDSA = "EdDSA" + SchemaVersion = 1 +) + +type VerificationMode string + +const ( + ModeOffline VerificationMode = "offline" + ModeHybrid VerificationMode = "hybrid" + ModeOnline VerificationMode = "online" +) + +type Header struct { + Algorithm string `json:"alg"` + Type string `json:"typ"` + KeyID string `json:"kid"` + Version int `json:"v"` +} + +type VerificationPolicy struct { + Mode VerificationMode `json:"mode"` + LeaseTTLSeconds int64 `json:"leaseTtlSeconds,omitempty"` + OfflineGraceSeconds int64 `json:"offlineGraceSeconds,omitempty"` + ServerURL string `json:"serverUrl,omitempty"` +} + +type Claims struct { + Version int `json:"version"` + LicenseID string `json:"licenseId"` + Issuer string `json:"issuer"` + Customer string `json:"customer"` + Product string `json:"product"` + Edition string `json:"edition"` + Features []string `json:"features,omitempty"` + Limits map[string]int64 `json:"limits,omitempty"` + Domains []string `json:"domains,omitempty"` + InstanceIDs []string `json:"instanceIds,omitempty"` + IssuedAt int64 `json:"issuedAt"` + NotBefore int64 `json:"notBefore,omitempty"` + ExpiresAt int64 `json:"expiresAt"` + Verification VerificationPolicy `json:"verification"` + Metadata map[string]string `json:"metadata,omitempty"` +} + +type LeaseClaims struct { + Version int `json:"version"` + LeaseID string `json:"leaseId"` + LicenseID string `json:"licenseId"` + Product string `json:"product"` + Customer string `json:"customer"` + Edition string `json:"edition"` + Features []string `json:"features,omitempty"` + Host string `json:"host,omitempty"` + InstanceID string `json:"instanceId,omitempty"` + IssuedAt int64 `json:"issuedAt"` + ExpiresAt int64 `json:"expiresAt"` +} + +type TrustStore struct { + LicenseKeys map[string]string `json:"licenseKeys"` + LeaseKeys map[string]string `json:"leaseKeys"` +} + +type VerifiedLicense struct { + Header Header + Claims Claims +} + +type VerifiedLease struct { + Header Header + Claims LeaseClaims +} + +func NewTrustStore() TrustStore { + return TrustStore{LicenseKeys: map[string]string{}, LeaseKeys: map[string]string{}} +} + +func ParseTrustStore(data []byte) (TrustStore, error) { + var store TrustStore + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + if err := dec.Decode(&store); err != nil { + return TrustStore{}, fmt.Errorf("decode trust store: %w", err) + } + if store.LicenseKeys == nil { + store.LicenseKeys = map[string]string{} + } + if store.LeaseKeys == nil { + store.LeaseKeys = map[string]string{} + } + for kid, encoded := range store.LicenseKeys { + if strings.TrimSpace(kid) == "" { + return TrustStore{}, errors.New("license trust store contains an empty key id") + } + if _, err := DecodePublicKey(encoded); err != nil { + return TrustStore{}, fmt.Errorf("license key %q: %w", kid, err) + } + } + for kid, encoded := range store.LeaseKeys { + if strings.TrimSpace(kid) == "" { + return TrustStore{}, errors.New("lease trust store contains an empty key id") + } + if _, err := DecodePublicKey(encoded); err != nil { + return TrustStore{}, fmt.Errorf("lease key %q: %w", kid, err) + } + } + return store, nil +} + +func MarshalTrustStore(store TrustStore) ([]byte, error) { + if store.LicenseKeys == nil { + store.LicenseKeys = map[string]string{} + } + if store.LeaseKeys == nil { + store.LeaseKeys = map[string]string{} + } + return json.MarshalIndent(store, "", " ") +} + +func SignLicense(privateKey ed25519.PrivateKey, keyID string, claims Claims) (string, error) { + if err := validateLicenseClaims(claims, time.Unix(claims.IssuedAt, 0), false); err != nil { + return "", err + } + return sign(TokenTypeLicense, keyID, privateKey, claims) +} + +func SignLease(privateKey ed25519.PrivateKey, keyID string, claims LeaseClaims) (string, error) { + if err := validateLeaseClaims(claims, time.Unix(claims.IssuedAt, 0), false); err != nil { + return "", err + } + return sign(TokenTypeLease, keyID, privateKey, claims) +} + +func sign(tokenType, keyID string, privateKey ed25519.PrivateKey, claims any) (string, error) { + if len(privateKey) != ed25519.PrivateKeySize { + return "", errors.New("invalid Ed25519 private key") + } + keyID = strings.TrimSpace(keyID) + if keyID == "" { + return "", errors.New("key id is required") + } + header := Header{Algorithm: AlgorithmEdDSA, Type: tokenType, KeyID: keyID, Version: SchemaVersion} + headerJSON, err := json.Marshal(header) + if err != nil { + return "", fmt.Errorf("marshal token header: %w", err) + } + payloadJSON, err := json.Marshal(claims) + if err != nil { + return "", fmt.Errorf("marshal token payload: %w", err) + } + headerPart := base64.RawURLEncoding.EncodeToString(headerJSON) + payloadPart := base64.RawURLEncoding.EncodeToString(payloadJSON) + signingInput := headerPart + "." + payloadPart + signature := ed25519.Sign(privateKey, []byte(signingInput)) + return signingInput + "." + base64.RawURLEncoding.EncodeToString(signature), nil +} + +func VerifyLicense(store TrustStore, token string, now time.Time) (VerifiedLicense, error) { + header, payload, err := verifyToken(store.LicenseKeys, TokenTypeLicense, token) + if err != nil { + return VerifiedLicense{}, err + } + var claims Claims + if err := decodeStrict(payload, &claims); err != nil { + return VerifiedLicense{}, fmt.Errorf("decode license payload: %w", err) + } + if err := validateLicenseClaims(claims, now, true); err != nil { + return VerifiedLicense{}, err + } + claims.Features = UniqueSorted(claims.Features) + claims.Domains = UniqueSorted(claims.Domains) + claims.InstanceIDs = UniqueSorted(claims.InstanceIDs) + return VerifiedLicense{Header: header, Claims: claims}, nil +} + +func VerifyLease(store TrustStore, token string, now time.Time, allowGrace time.Duration) (VerifiedLease, error) { + header, payload, err := verifyToken(store.LeaseKeys, TokenTypeLease, token) + if err != nil { + return VerifiedLease{}, err + } + var claims LeaseClaims + if err := decodeStrict(payload, &claims); err != nil { + return VerifiedLease{}, fmt.Errorf("decode lease payload: %w", err) + } + if err := validateLeaseClaims(claims, now, false); err != nil { + return VerifiedLease{}, err + } + if now.Unix() >= claims.ExpiresAt+int64(allowGrace.Seconds()) { + return VerifiedLease{}, errors.New("lease has expired") + } + claims.Features = UniqueSorted(claims.Features) + return VerifiedLease{Header: header, Claims: claims}, nil +} + +func verifyToken(keys map[string]string, expectedType, token string) (Header, []byte, error) { + parts := strings.Split(strings.TrimSpace(token), ".") + if len(parts) != 3 { + return Header{}, nil, errors.New("token has invalid format") + } + headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return Header{}, nil, errors.New("token header is not valid base64url") + } + var header Header + if err := decodeStrict(headerBytes, &header); err != nil { + return Header{}, nil, fmt.Errorf("decode token header: %w", err) + } + if header.Algorithm != AlgorithmEdDSA || header.Type != expectedType || header.Version != SchemaVersion { + return Header{}, nil, errors.New("unsupported token header") + } + encodedKey, ok := keys[header.KeyID] + if !ok { + return Header{}, nil, fmt.Errorf("token is signed by unknown key %q", header.KeyID) + } + publicKey, err := DecodePublicKey(encodedKey) + if err != nil { + return Header{}, nil, fmt.Errorf("decode trusted key %q: %w", header.KeyID, err) + } + signature, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil { + return Header{}, nil, errors.New("token signature is not valid base64url") + } + signingInput := parts[0] + "." + parts[1] + if !ed25519.Verify(publicKey, []byte(signingInput), signature) { + return Header{}, nil, errors.New("token signature verification failed") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return Header{}, nil, errors.New("token payload is not valid base64url") + } + return header, payload, nil +} + +func ValidateLicenseContext(claims Claims, product, baseURL, instanceID string) error { + if strings.TrimSpace(product) == "" { + return errors.New("client product id is required") + } + if claims.Product != product { + return fmt.Errorf("license is for product %q, not %q", claims.Product, product) + } + if err := ValidateDomain(claims.Domains, baseURL); err != nil { + return err + } + if len(claims.InstanceIDs) > 0 { + instanceID = strings.TrimSpace(instanceID) + if instanceID == "" { + return errors.New("license requires an instance id") + } + allowed := false + for _, candidate := range claims.InstanceIDs { + if candidate == "*" || candidate == instanceID { + allowed = true + break + } + } + if !allowed { + return fmt.Errorf("instance %q is not covered by the license", instanceID) + } + } + return nil +} + +func ValidateLeaseContext(claims LeaseClaims, license Claims, product, baseURL, instanceID string) error { + if claims.LicenseID != license.LicenseID { + return errors.New("lease does not belong to the configured license") + } + if claims.Product != product || claims.Product != license.Product { + return errors.New("lease product does not match") + } + host, err := HostFromBaseURL(baseURL) + if err != nil { + return err + } + if claims.Host != "" && !strings.EqualFold(claims.Host, host) { + return errors.New("lease host does not match") + } + if claims.InstanceID != "" && claims.InstanceID != instanceID { + return errors.New("lease instance does not match") + } + return nil +} + +func ValidateDomain(domains []string, baseURL string) error { + if len(domains) == 0 { + return nil + } + host, err := HostFromBaseURL(baseURL) + if err != nil { + return err + } + for _, allowed := range domains { + allowed = strings.ToLower(strings.TrimSpace(allowed)) + if allowed == "*" || host == allowed { + return nil + } + if strings.HasPrefix(allowed, "*.") { + root := strings.TrimPrefix(allowed, "*.") + if host != root && strings.HasSuffix(host, "."+root) { + return nil + } + } + } + return fmt.Errorf("host %q is not covered by the license", host) +} + +func HostFromBaseURL(baseURL string) (string, error) { + u, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil || u.Hostname() == "" { + return "", errors.New("base URL has no valid host") + } + return strings.ToLower(u.Hostname()), nil +} + +func StricterMode(a, b VerificationMode) VerificationMode { + rank := map[VerificationMode]int{ModeOffline: 0, ModeHybrid: 1, ModeOnline: 2} + if rank[b] > rank[a] { + return b + } + if _, ok := rank[a]; !ok { + return ModeOffline + } + return a +} + +func ParseMode(value string) (VerificationMode, error) { + mode := VerificationMode(strings.ToLower(strings.TrimSpace(value))) + switch mode { + case "", ModeOffline: + return ModeOffline, nil + case ModeHybrid, ModeOnline: + return mode, nil + default: + return "", fmt.Errorf("unknown verification mode %q", value) + } +} + +func TokenHash(token string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(token))) + return hex.EncodeToString(sum[:]) +} + +func DecodePrivateKey(encoded string) (ed25519.PrivateKey, error) { + b, err := decodeKey(encoded) + if err != nil { + return nil, err + } + if len(b) == ed25519.SeedSize { + return ed25519.NewKeyFromSeed(b), nil + } + if len(b) != ed25519.PrivateKeySize { + return nil, errors.New("private key must contain an Ed25519 seed or private key") + } + return ed25519.PrivateKey(b), nil +} + +func DecodePublicKey(encoded string) (ed25519.PublicKey, error) { + b, err := decodeKey(encoded) + if err != nil { + return nil, err + } + if len(b) != ed25519.PublicKeySize { + return nil, errors.New("public key must contain an Ed25519 public key") + } + return ed25519.PublicKey(b), nil +} + +func EncodeKey(key []byte) string { return base64.RawURLEncoding.EncodeToString(key) } + +func decodeKey(value string) ([]byte, error) { + value = strings.TrimSpace(value) + if b, err := base64.RawURLEncoding.DecodeString(value); err == nil { + return b, nil + } + if b, err := base64.StdEncoding.DecodeString(value); err == nil { + return b, nil + } + return nil, errors.New("key is not valid base64") +} + +func UniqueSorted(values []string) []string { + seen := map[string]bool{} + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" && !seen[value] { + seen[value] = true + out = append(out, value) + } + } + sort.Strings(out) + return out +} + +func validateLicenseClaims(c Claims, now time.Time, checkTime bool) error { + if c.Version != SchemaVersion { + return errors.New("unsupported license version") + } + if strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Issuer) == "" { + return errors.New("license id and issuer are required") + } + if strings.TrimSpace(c.Customer) == "" || strings.TrimSpace(c.Product) == "" || strings.TrimSpace(c.Edition) == "" { + return errors.New("customer, product and edition are required") + } + if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt { + return errors.New("license timestamps are invalid") + } + notBefore := c.NotBefore + if notBefore == 0 { + notBefore = c.IssuedAt + } + if checkTime { + if now.Unix() < notBefore-300 { + return errors.New("license is not active yet") + } + if now.Unix() >= c.ExpiresAt { + return errors.New("license has expired") + } + } + if _, err := ParseMode(string(c.Verification.Mode)); err != nil { + return err + } + if c.Verification.LeaseTTLSeconds < 0 || c.Verification.OfflineGraceSeconds < 0 { + return errors.New("verification durations cannot be negative") + } + if raw := strings.TrimSpace(c.Verification.ServerURL); raw != "" { + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") { + return errors.New("verification server URL must be an absolute HTTP(S) URL") + } + } + return nil +} + +func validateLeaseClaims(c LeaseClaims, now time.Time, checkExpiration bool) error { + if c.Version != SchemaVersion { + return errors.New("unsupported lease version") + } + if strings.TrimSpace(c.LeaseID) == "" || strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Product) == "" { + return errors.New("lease id, license id and product are required") + } + if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt { + return errors.New("lease timestamps are invalid") + } + if now.Unix() < c.IssuedAt-300 { + return errors.New("lease is not active yet") + } + if checkExpiration && now.Unix() >= c.ExpiresAt { + return errors.New("lease has expired") + } + return nil +} + +func decodeStrict(data []byte, target any) error { + dec := json.NewDecoder(strings.NewReader(string(data))) + dec.DisallowUnknownFields() + if err := dec.Decode(target); err != nil { + return err + } + return nil +} diff --git a/pkg/licensekit/licensekit_test.go b/pkg/licensekit/licensekit_test.go new file mode 100644 index 0000000..b364bf4 --- /dev/null +++ b/pkg/licensekit/licensekit_test.go @@ -0,0 +1,84 @@ +package licensekit + +import ( + "crypto/ed25519" + "crypto/rand" + "testing" + "time" +) + +func testKeys(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return pub, priv +} + +func TestLicenseRoundTripAndContext(t *testing.T) { + pub, priv := testKeys(t) + now := time.Now().UTC() + claims := Claims{Version: 1, LicenseID: "lic_test", Issuer: "vendor", Customer: "customer", Product: "product-a", Edition: "pro", Features: []string{"b", "a"}, Domains: []string{"*.example.org"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: VerificationPolicy{Mode: ModeOffline}} + token, err := SignLicense(priv, "issuer-1", claims) + if err != nil { + t.Fatal(err) + } + store := NewTrustStore() + store.LicenseKeys["issuer-1"] = EncodeKey(pub) + verified, err := VerifyLicense(store, token, now) + if err != nil { + t.Fatal(err) + } + if verified.Claims.Features[0] != "a" { + t.Fatalf("features not sorted: %#v", verified.Claims.Features) + } + if err := ValidateLicenseContext(verified.Claims, "product-a", "https://app.example.org", ""); err != nil { + t.Fatal(err) + } + if err := ValidateLicenseContext(verified.Claims, "product-b", "https://app.example.org", ""); err == nil { + t.Fatal("expected product mismatch") + } +} + +func TestGlobalWildcardAllowsAllHosts(t *testing.T) { + if err := ValidateDomain([]string{"*"}, "http://localhost:8080"); err != nil { + t.Fatal(err) + } + if err := ValidateDomain([]string{"*"}, "https://anything.invalid"); err != nil { + t.Fatal(err) + } +} + +func TestUnknownKeyIsRejected(t *testing.T) { + _, priv := testKeys(t) + now := time.Now().UTC() + claims := Claims{Version: 1, LicenseID: "lic_test", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: VerificationPolicy{Mode: ModeOffline}} + token, err := SignLicense(priv, "self-chosen", claims) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyLicense(NewTrustStore(), token, now); err == nil { + t.Fatal("untrusted user key must not be accepted") + } +} + +func TestLeaseRoundTrip(t *testing.T) { + pub, priv := testKeys(t) + now := time.Now().UTC() + claims := LeaseClaims{Version: 1, LeaseID: "lease_1", LicenseID: "lic_1", Product: "product", Customer: "customer", Edition: "pro", Features: []string{"x"}, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix()} + token, err := SignLease(priv, "lease-1", claims) + if err != nil { + t.Fatal(err) + } + store := NewTrustStore() + store.LeaseKeys["lease-1"] = EncodeKey(pub) + verified, err := VerifyLease(store, token, now, 0) + if err != nil { + t.Fatal(err) + } + license := Claims{LicenseID: "lic_1", Product: "product"} + if err := ValidateLeaseContext(verified.Claims, license, "product", "https://example.org", ""); err != nil { + t.Fatal(err) + } +} diff --git a/run.ps1 b/run.ps1 new file mode 100644 index 0000000..400eed4 --- /dev/null +++ b/run.ps1 @@ -0,0 +1,35 @@ +$ErrorActionPreference = "Stop" + +$envFile = Join-Path $PSScriptRoot ".env" + +if (-not (Test-Path $envFile)) { + throw "Keine .env-Datei gefunden: $envFile" +} + +Get-Content $envFile | ForEach-Object { + $line = $_.Trim() + + if ($line -and -not $line.StartsWith("#")) { + $parts = $line -split "=", 2 + + if ($parts.Count -eq 2) { + $name = $parts[0].Trim() + $value = $parts[1].Trim() + + if ( + ($value.StartsWith('"') -and $value.EndsWith('"')) -or + ($value.StartsWith("'") -and $value.EndsWith("'")) + ) { + $value = $value.Substring(1, $value.Length - 2) + } + + [Environment]::SetEnvironmentVariable( + $name, + $value, + "Process" + ) + } + } +} + +go run .\cmd\server \ No newline at end of file diff --git a/sdk/go/licenseclient/client.go b/sdk/go/licenseclient/client.go new file mode 100644 index 0000000..219f43f --- /dev/null +++ b/sdk/go/licenseclient/client.go @@ -0,0 +1,450 @@ +package licenseclient + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/b1tsblog/license-platform/pkg/licensekit" +) + +type Status struct { + Edition string `json:"edition"` + Licensed bool `json:"licensed"` + LicenseID string `json:"licenseId,omitempty"` + Customer string `json:"customer,omitempty"` + Product string `json:"product,omitempty"` + Features []string `json:"features"` + Limits map[string]int64 `json:"limits,omitempty"` + ExpiresAt string `json:"expiresAt,omitempty"` + Mode string `json:"mode,omitempty"` + Source string `json:"source,omitempty"` + LastChecked string `json:"lastChecked,omitempty"` + LeaseExpires string `json:"leaseExpiresAt,omitempty"` + Reason string `json:"reason,omitempty"` + ServerURL string `json:"serverUrl,omitempty"` +} + +type Config struct { + Product string + Token string + TrustStore licensekit.TrustStore + BaseURL string + InstanceID string + Mode licensekit.VerificationMode + ServerURL string + CacheFile string + RefreshEvery time.Duration + RequestTimeout time.Duration + ClientVersion string + HTTPClient *http.Client +} + +type Client struct { + cfg Config + mu sync.RWMutex + status Status + claims licensekit.Claims + features map[string]bool + stopOnce sync.Once + stop chan struct{} +} + +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"` + Reason string `json:"reason,omitempty"` +} + +type cacheDocument struct { + LicenseID string `json:"licenseId"` + Lease string `json:"lease"` + SavedAt int64 `json:"savedAt"` +} + +func New(ctx context.Context, cfg Config) *Client { + cfg.ServerURL = strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/") + if cfg.ServerURL == "" { + cfg.ServerURL = strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/") + } + if cfg.RefreshEvery <= 0 { + cfg.RefreshEvery = 15 * time.Minute + } + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = 5 * time.Second + } + if cfg.HTTPClient == nil { + cfg.HTTPClient = &http.Client{Timeout: cfg.RequestTimeout} + } + c := &Client{cfg: cfg, stop: make(chan struct{}), status: communityStatus(), features: map[string]bool{}} + c.refresh(ctx) + return c +} + +func (c *Client) Start(ctx context.Context) { + c.mu.RLock() + mode := c.status.Mode + c.mu.RUnlock() + if mode == string(licensekit.ModeOffline) || strings.TrimSpace(c.cfg.Token) == "" { + return + } + go func() { + ticker := time.NewTicker(c.cfg.RefreshEvery) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-c.stop: + return + case <-ticker.C: + refreshCtx, cancel := context.WithTimeout(context.Background(), c.cfg.RequestTimeout) + c.refresh(refreshCtx) + cancel() + } + } + }() +} + +func (c *Client) Close() { c.stopOnce.Do(func() { close(c.stop) }) } + +func (c *Client) Refresh(ctx context.Context) Status { + c.refresh(ctx) + return c.Status() +} + +func (c *Client) Has(feature string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.isCurrentlyLicensedLocked(time.Now().UTC()) && c.features[feature] +} + +func (c *Client) Limit(name string) (int64, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + if !c.isCurrentlyLicensedLocked(time.Now().UTC()) { + return 0, false + } + value, ok := c.status.Limits[name] + return value, ok +} + +func (c *Client) Status() Status { + c.mu.RLock() + defer c.mu.RUnlock() + out := c.status + if out.Licensed && !c.isCurrentlyLicensedLocked(time.Now().UTC()) { + out.Licensed = false + out.Edition = "community" + if out.Reason == "" { + out.Reason = "license or online lease is no longer valid" + } + } + out.Features = append([]string{}, c.status.Features...) + if c.status.Limits != nil { + out.Limits = make(map[string]int64, len(c.status.Limits)) + for key, value := range c.status.Limits { + out.Limits[key] = value + } + } + return out +} + +func (c *Client) isCurrentlyLicensedLocked(now time.Time) bool { + if !c.status.Licensed { + return false + } + if c.claims.ExpiresAt > 0 && now.Unix() >= c.claims.ExpiresAt { + return false + } + mode := licensekit.VerificationMode(c.status.Mode) + if mode == licensekit.ModeOffline || c.status.LeaseExpires == "" { + return true + } + leaseExpiry, err := time.Parse(time.RFC3339, c.status.LeaseExpires) + if err != nil { + return false + } + if mode == licensekit.ModeHybrid { + leaseExpiry = leaseExpiry.Add(time.Duration(c.claims.Verification.OfflineGraceSeconds) * time.Second) + } + return now.Before(leaseExpiry) +} + +func (c *Client) refresh(ctx context.Context) { + now := time.Now().UTC() + if strings.TrimSpace(c.cfg.Token) == "" { + c.apply(communityStatus(), licensekit.Claims{}) + return + } + verified, err := licensekit.VerifyLicense(c.cfg.TrustStore, c.cfg.Token, now) + if err != nil { + c.apply(failedStatus(err.Error(), now), licensekit.Claims{}) + return + } + claims := verified.Claims + if err := licensekit.ValidateLicenseContext(claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil { + c.apply(failedStatus(err.Error(), now), claims) + return + } + mode := licensekit.StricterMode(claims.Verification.Mode, c.cfg.Mode) + base := statusFromClaims(claims, mode, now) + if mode == licensekit.ModeOffline { + base.Licensed = true + base.Source = "offline" + c.apply(base, claims) + return + } + serverURL := c.resolveServerURL(ctx, claims) + base.ServerURL = serverURL + if serverURL == "" { + base.Edition = "community" + base.Reason = "online verification is required but no license server URL could be resolved" + c.apply(base, claims) + return + } + lease, source, err := c.obtainLease(ctx, claims, mode, now, serverURL) + if err != nil { + base.Edition = "community" + base.Reason = err.Error() + c.apply(base, claims) + return + } + base.Licensed = true + base.Source = source + base.LeaseExpires = time.Unix(lease.ExpiresAt, 0).UTC().Format(time.RFC3339) + base.Features = licensekit.UniqueSorted(intersection(claims.Features, lease.Features)) + c.apply(base, claims) +} + +func (c *Client) obtainLease(ctx context.Context, claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time, serverURL string) (licensekit.LeaseClaims, string, error) { + leaseToken, err := c.requestLease(ctx, serverURL) + if err == nil { + lease, verifyErr := licensekit.VerifyLease(c.cfg.TrustStore, leaseToken, now, 0) + if verifyErr != nil { + return licensekit.LeaseClaims{}, "", fmt.Errorf("online lease verification failed: %w", verifyErr) + } + if verifyErr = licensekit.ValidateLeaseContext(lease.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); verifyErr != nil { + return licensekit.LeaseClaims{}, "", verifyErr + } + _ = c.writeCache(claims.LicenseID, leaseToken) + return lease.Claims, "online", nil + } + if mode == licensekit.ModeOnline { + return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed: %w", err) + } + cached, cacheErr := c.readCache(claims, now) + if cacheErr != nil { + return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed (%v) and no usable cached lease exists (%v)", err, cacheErr) + } + return cached, "cached-lease", nil +} + +func (c *Client) resolveServerURL(ctx context.Context, claims licensekit.Claims) string { + if value := strings.TrimRight(strings.TrimSpace(c.cfg.ServerURL), "/"); value != "" { + return value + } + if value := strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/"); value != "" { + return value + } + if value := strings.TrimRight(strings.TrimSpace(claims.Verification.ServerURL), "/"); value != "" { + return value + } + base := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/") + if base == "" { + return "" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/license-server", nil) + if err != nil { + return "" + } + req.Header.Set("Accept", "application/json") + resp, err := c.cfg.HTTPClient.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "" + } + var discovery struct { + ServerURL string `json:"serverUrl"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&discovery); err != nil { + return "" + } + return strings.TrimRight(strings.TrimSpace(discovery.ServerURL), "/") +} + +func (c *Client) requestLease(ctx context.Context, serverURL string) (string, error) { + host, err := licensekit.HostFromBaseURL(c.cfg.BaseURL) + if err != nil { + return "", err + } + body, err := json.Marshal(introspectRequest{Token: c.cfg.Token, Product: c.cfg.Product, BaseURL: c.cfg.BaseURL, Host: host, InstanceID: c.cfg.InstanceID, ClientVersion: c.cfg.ClientVersion}) + if err != nil { + return "", err + } + paths := []string{"/api/v1/licenses/validate", "/v1/introspect"} + var lastErr error + for index, path := range paths { + token, status, err := c.requestLeaseAt(ctx, strings.TrimRight(serverURL, "/")+path, body) + if err == nil { + return token, nil + } + lastErr = err + if index == 0 && status == http.StatusNotFound { + continue + } + break + } + return "", lastErr +} + +func (c *Client) requestLeaseAt(ctx context.Context, endpoint string, body []byte) (string, int, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return "", 0, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := c.cfg.HTTPClient.Do(req) + if err != nil { + return "", 0, err + } + defer resp.Body.Close() + payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", resp.StatusCode, err + } + var result introspectResponse + if err := json.Unmarshal(payload, &result); err != nil { + return "", resp.StatusCode, fmt.Errorf("decode verification response: %w", err) + } + if resp.StatusCode != http.StatusOK || !result.Valid || result.LeaseToken == "" { + if result.Reason == "" { + result.Reason = resp.Status + } + return "", resp.StatusCode, errors.New(result.Reason) + } + return result.LeaseToken, resp.StatusCode, nil +} + +func (c *Client) readCache(claims licensekit.Claims, now time.Time) (licensekit.LeaseClaims, error) { + if strings.TrimSpace(c.cfg.CacheFile) == "" { + return licensekit.LeaseClaims{}, errors.New("cache file is not configured") + } + data, err := os.ReadFile(c.cfg.CacheFile) + if err != nil { + return licensekit.LeaseClaims{}, err + } + var doc cacheDocument + if err := json.Unmarshal(data, &doc); err != nil { + return licensekit.LeaseClaims{}, err + } + if doc.LicenseID != claims.LicenseID { + return licensekit.LeaseClaims{}, errors.New("cached lease belongs to another license") + } + grace := time.Duration(claims.Verification.OfflineGraceSeconds) * time.Second + verified, err := licensekit.VerifyLease(c.cfg.TrustStore, doc.Lease, now, grace) + if err != nil { + return licensekit.LeaseClaims{}, err + } + if err := licensekit.ValidateLeaseContext(verified.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil { + return licensekit.LeaseClaims{}, err + } + return verified.Claims, nil +} + +func (c *Client) writeCache(licenseID, lease string) error { + if strings.TrimSpace(c.cfg.CacheFile) == "" { + return nil + } + if err := os.MkdirAll(filepath.Dir(c.cfg.CacheFile), 0o700); err != nil && filepath.Dir(c.cfg.CacheFile) != "." { + return err + } + data, err := json.Marshal(cacheDocument{LicenseID: licenseID, Lease: lease, SavedAt: time.Now().UTC().Unix()}) + if err != nil { + return err + } + temp := c.cfg.CacheFile + ".tmp" + if err := os.WriteFile(temp, data, 0o600); err != nil { + return err + } + return os.Rename(temp, c.cfg.CacheFile) +} + +func (c *Client) apply(status Status, claims licensekit.Claims) { + status.Features = licensekit.UniqueSorted(status.Features) + c.mu.Lock() + defer c.mu.Unlock() + c.status = status + c.claims = claims + c.features = make(map[string]bool, len(status.Features)) + for _, feature := range status.Features { + c.features[feature] = true + } +} + +func communityStatus() Status { + return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}} +} + +func failedStatus(reason string, now time.Time) Status { + return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}, Reason: reason, LastChecked: now.Format(time.RFC3339)} +} + +func statusFromClaims(claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) Status { + limits := map[string]int64{} + for key, value := range claims.Limits { + limits[key] = value + } + return Status{ + Edition: claims.Edition, LicenseID: claims.LicenseID, Customer: claims.Customer, Product: claims.Product, + Features: append([]string(nil), claims.Features...), Limits: limits, + ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC().Format(time.RFC3339), Mode: string(mode), LastChecked: now.Format(time.RFC3339), + } +} + +func intersection(a, b []string) []string { + allowed := make(map[string]bool, len(b)) + for _, value := range b { + allowed[value] = true + } + out := make([]string, 0, len(a)) + for _, value := range a { + if allowed[value] { + out = append(out, value) + } + } + return out +} + +// NewDevelopment returns an in-memory licensed client for local development. +// Production applications should not expose this path without an explicit development switch. +func NewDevelopment(product, edition string, features []string) *Client { + status := Status{Edition: edition, Licensed: true, Customer: "development", Product: product, Features: licensekit.UniqueSorted(features), Limits: map[string]int64{}, Mode: string(licensekit.ModeOffline), Source: "development", Reason: "insecure development override"} + c := &Client{status: status, features: map[string]bool{}, stop: make(chan struct{})} + for _, feature := range status.Features { + c.features[feature] = true + } + return c +} diff --git a/sdk/go/licenseclient/client_test.go b/sdk/go/licenseclient/client_test.go new file mode 100644 index 0000000..aa156a8 --- /dev/null +++ b/sdk/go/licenseclient/client_test.go @@ -0,0 +1,113 @@ +package licenseclient + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + "time" + + "github.com/b1tsblog/license-platform/pkg/licensekit" +) + +func keys(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + return pub, priv +} + +func licenseToken(t *testing.T, mode licensekit.VerificationMode, store *licensekit.TrustStore) (string, licensekit.Claims) { + t.Helper() + pub, priv := keys(t) + store.LicenseKeys["issuer"] = licensekit.EncodeKey(pub) + now := time.Now().UTC() + claims := licensekit.Claims{Version: 1, LicenseID: "lic_1", Issuer: "vendor", Customer: "customer", Product: "product", Edition: "pro", Features: []string{"feature_a", "feature_b"}, Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: mode, LeaseTTLSeconds: 600, OfflineGraceSeconds: 3600}} + token, err := licensekit.SignLicense(priv, "issuer", claims) + if err != nil { + t.Fatal(err) + } + return token, claims +} + +func TestOfflineClient(t *testing.T) { + store := licensekit.NewTrustStore() + token, _ := licenseToken(t, licensekit.ModeOffline, &store) + c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org"}) + if !c.Status().Licensed || !c.Has("feature_a") { + t.Fatalf("unexpected status %#v", c.Status()) + } +} + +func TestHybridClientUsesOnlineLeaseAndCache(t *testing.T) { + store := licensekit.NewTrustStore() + token, claims := licenseToken(t, licensekit.ModeHybrid, &store) + leasePub, leasePriv := keys(t) + store.LeaseKeys["lease"] = licensekit.EncodeKey(leasePub) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + now := time.Now().UTC() + lease := licensekit.LeaseClaims{Version: 1, LeaseID: "lease_1", LicenseID: claims.LicenseID, Product: claims.Product, Customer: claims.Customer, Edition: claims.Edition, Features: claims.Features, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(5 * time.Minute).Unix()} + leaseToken, err := licensekit.SignLease(leasePriv, "lease", lease) + if err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": leaseToken}) + })) + cache := filepath.Join(t.TempDir(), "lease.json") + c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeHybrid, ServerURL: server.URL, CacheFile: cache}) + if !c.Status().Licensed || c.Status().Source != "online" { + t.Fatalf("unexpected online status %#v", c.Status()) + } + server.Close() + c.Refresh(context.Background()) + if !c.Status().Licensed || c.Status().Source != "cached-lease" { + t.Fatalf("unexpected cached status %#v", c.Status()) + } +} + +func TestOnlineModeFailsWithoutServer(t *testing.T) { + store := licensekit.NewTrustStore() + token, _ := licenseToken(t, licensekit.ModeOnline, &store) + c := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeOnline}) + if c.Status().Licensed { + t.Fatalf("online license unexpectedly active %#v", c.Status()) + } +} + +func TestUsesServerURLEmbeddedInLicense(t *testing.T) { + issuerPub, issuerPriv := keys(t) + leasePub, leasePriv := keys(t) + store := licensekit.NewTrustStore() + store.LicenseKeys["issuer"] = licensekit.EncodeKey(issuerPub) + store.LeaseKeys["lease"] = licensekit.EncodeKey(leasePub) + now := time.Now().UTC() + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/licenses/validate" { + http.NotFound(w, r) + return + } + lease, err := licensekit.SignLease(leasePriv, "lease", licensekit.LeaseClaims{Version: 1, LeaseID: "lease", LicenseID: "license", Product: "product", Customer: "customer", Edition: "pro", Features: []string{"feature"}, Host: "example.org", IssuedAt: now.Unix(), ExpiresAt: now.Add(time.Hour).Unix()}) + if err != nil { + t.Fatal(err) + } + _ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": lease}) + })) + defer server.Close() + claims := licensekit.Claims{Version: 1, LicenseID: "license", Issuer: "issuer", Customer: "customer", Product: "product", Edition: "pro", Features: []string{"feature"}, Domains: []string{"*"}, IssuedAt: now.Unix(), ExpiresAt: now.Add(24 * time.Hour).Unix(), Verification: licensekit.VerificationPolicy{Mode: licensekit.ModeHybrid, ServerURL: server.URL}} + token, err := licensekit.SignLicense(issuerPriv, "issuer", claims) + if err != nil { + t.Fatal(err) + } + client := New(context.Background(), Config{Product: "product", Token: token, TrustStore: store, BaseURL: "https://example.org", Mode: licensekit.ModeHybrid}) + status := client.Status() + if !status.Licensed || status.ServerURL != server.URL { + t.Fatalf("embedded URL was not used: %#v", status) + } +} diff --git a/sdk/go/licenseclient/doc.go b/sdk/go/licenseclient/doc.go new file mode 100644 index 0000000..f7df325 --- /dev/null +++ b/sdk/go/licenseclient/doc.go @@ -0,0 +1,4 @@ +// Package licenseclient implements the runtime side of product licensing. It +// supports offline verification, hybrid signed-lease caching and mandatory +// online introspection without allowing customers to replace trusted keys. +package licenseclient diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..305c08a --- /dev/null +++ b/web/embed.go @@ -0,0 +1,8 @@ +package webassets + +import "embed" + +// FS contains the portal templates and static assets. +// +//go:embed templates/*.html static/* +var FS embed.FS diff --git a/web/static/app.css b/web/static/app.css new file mode 100644 index 0000000..ec5c685 --- /dev/null +++ b/web/static/app.css @@ -0,0 +1,2 @@ +:root{--bg:#07110f;--panel:#0d1c19;--panel2:#112522;--line:#244039;--text:#eef8f4;--muted:#8eaaa1;--accent:#61e6b2;--accent2:#b4ffdd;--danger:#ff7d78;--warning:#ffc66d;--shadow:0 28px 70px rgba(0,0,0,.28);font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text);background:var(--bg)}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at 85% -10%,rgba(97,230,178,.17),transparent 32rem),linear-gradient(180deg,#081512,#07110f 55%)}a{color:inherit;text-decoration:none}button,input,textarea,select{font:inherit}.topbar{position:sticky;top:0;z-index:20;display:flex;justify-content:space-between;align-items:center;padding:15px clamp(18px,4vw,58px);border-bottom:1px solid rgba(255,255,255,.08);background:rgba(7,17,15,.82);backdrop-filter:blur(18px)}.brand,.top-actions{display:flex;align-items:center;gap:12px}.brand{font-weight:800;letter-spacing:-.02em}.brand-mark{display:grid;place-items:center;width:54px;height:54px;border-radius:17px;background:linear-gradient(135deg,var(--accent),#23a77e);color:#052019;font-size:1.5rem;font-weight:900;box-shadow:0 12px 30px rgba(97,230,178,.22)}.brand-mark.small{width:34px;height:34px;border-radius:11px;font-size:1rem}.user-name{color:var(--muted);font-size:.92rem}.role-pill,.badge,.mode,.status{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:999px;padding:5px 10px;font-size:.76rem;font-weight:750;letter-spacing:.02em;background:rgba(255,255,255,.03)}.role-pill{color:var(--accent2)}.shell{width:min(1450px,calc(100% - 36px));margin:auto;padding:48px 0 70px}.shell.narrow{width:min(900px,calc(100% - 36px))}.hero{display:flex;justify-content:space-between;align-items:flex-end;gap:30px;margin-bottom:32px}.hero h1{font-size:clamp(2.5rem,5vw,5.4rem);line-height:.95;letter-spacing:-.055em;max-width:900px;margin:.15em 0}.hero p{max-width:760px;color:var(--muted);line-height:1.65;font-size:1.05rem}.eyebrow{text-transform:uppercase;letter-spacing:.18em;font-size:.7rem;font-weight:900;color:var(--accent);margin:0 0 8px}.status-card{display:flex;align-items:center;gap:13px;min-width:300px;padding:16px 18px;border:1px solid var(--line);border-radius:17px;background:rgba(255,255,255,.025)}.status-card strong,.status-card small{display:block}.status-card small{color:var(--muted);margin-top:4px}.status-dot{width:12px;height:12px;border-radius:50%;box-shadow:0 0 0 6px rgba(255,198,109,.08)}.status-dot.ok{background:var(--accent);box-shadow:0 0 0 6px rgba(97,230,178,.08)}.status-dot.warn{background:var(--warning)}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:20px}.metrics article,.panel{border:1px solid var(--line);background:linear-gradient(160deg,rgba(255,255,255,.04),rgba(255,255,255,.015));border-radius:20px;box-shadow:var(--shadow)}.metrics article{padding:20px}.metrics span,.metrics small{display:block;color:var(--muted)}.metrics strong{display:block;font-size:2rem;margin:10px 0 6px;letter-spacing:-.04em}.metrics small{font-size:.78rem}.grid.two{display:grid;grid-template-columns:1fr 1fr;gap:20px}.panel{padding:24px;margin-bottom:20px}.accent-panel{background:radial-gradient(circle at 100% 0,rgba(97,230,178,.13),transparent 45%),var(--panel)}.panel-head{display:flex;justify-content:space-between;align-items:flex-start;gap:20px;margin-bottom:20px}.panel-head h2{font-size:1.35rem;margin:0;letter-spacing:-.025em}.badge-live{color:var(--accent);border-color:rgba(97,230,178,.3)}.stack{display:grid;gap:15px}.stack.compact{gap:11px}.form-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px}.form-grid.three{grid-template-columns:repeat(3,1fr)}label{display:grid;gap:7px;color:#bfd1cb;font-size:.82rem;font-weight:680}input,textarea,select{width:100%;border:1px solid #2d4a43;border-radius:11px;background:#091613;color:var(--text);padding:11px 12px;outline:none;transition:.18s border,.18s box-shadow}input:focus,textarea:focus,select:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(97,230,178,.1)}textarea{resize:vertical;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.78rem;line-height:1.55}.button{display:inline-flex;align-items:center;justify-content:center;border:1px solid transparent;border-radius:10px;padding:10px 15px;font-weight:800;cursor:pointer;background:transparent;color:var(--text)}.button.primary{background:var(--accent);color:#052019}.button.secondary{background:#d9fff0;color:#052019}.button.ghost{border-color:var(--line);background:rgba(255,255,255,.025)}.button.danger{border-color:rgba(255,125,120,.35);color:#ffaaa6;background:rgba(255,125,120,.06)}.button.tiny{padding:7px 10px;font-size:.75rem}.alert{padding:13px 15px;border:1px solid var(--line);border-radius:12px;background:rgba(255,255,255,.035);margin-bottom:18px;color:#c8dad4}.alert-success{border-color:rgba(97,230,178,.34);color:var(--accent2)}.alert-error{border-color:rgba(255,125,120,.4);color:#ffc1be}.muted,.micro{color:var(--muted);line-height:1.55}.micro{font-size:.75rem}.key-list{display:grid;gap:8px;margin:18px 0}.key-list div{display:flex;justify-content:space-between;gap:20px;border-bottom:1px solid rgba(255,255,255,.06);padding:8px 0}.key-list dt{color:var(--muted)}.key-list dd{margin:0;font-family:ui-monospace,monospace}.user-list{display:grid;gap:10px;margin-top:18px;max-height:210px;overflow:auto}.user-list>div{display:flex;align-items:center;gap:10px;padding:9px;border:1px solid rgba(255,255,255,.06);border-radius:12px}.user-list strong,.user-list small{display:block}.user-list small{color:var(--muted);margin-top:3px}.avatar{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;background:#19362f;color:var(--accent);font-weight:900}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse;min-width:900px}th,td{text-align:left;border-bottom:1px solid rgba(255,255,255,.07);padding:13px 10px;font-size:.86rem}th{color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}td strong,td small{display:block}td small{color:var(--muted);margin-top:4px}.status.active{color:var(--accent);border-color:rgba(97,230,178,.25)}.status.revoked{color:#ffaaa6;border-color:rgba(255,125,120,.28)}.row-actions{display:flex;gap:7px}.row-actions form{margin:0}.empty{padding:24px;text-align:center;color:var(--muted)}.audit-list{display:grid}.audit-list>div{display:grid;grid-template-columns:160px 180px 180px 1fr;gap:12px;padding:11px 0;border-bottom:1px solid rgba(255,255,255,.06);font-size:.82rem}.audit-list time,.audit-list small{color:var(--muted)}footer{display:flex;justify-content:space-between;padding:24px clamp(18px,4vw,58px);border-top:1px solid rgba(255,255,255,.07);color:var(--muted);font-size:.78rem}.auth-page{display:grid;place-items:center;padding:32px}.auth-shell{display:grid;grid-template-columns:1.25fr .75fr;width:min(1100px,100%);min-height:650px;border:1px solid var(--line);border-radius:28px;overflow:hidden;box-shadow:var(--shadow)}.auth-brand{padding:clamp(34px,6vw,78px);background:radial-gradient(circle at 0 100%,rgba(97,230,178,.19),transparent 45%),linear-gradient(145deg,#0f2721,#091713);display:flex;flex-direction:column;justify-content:center}.auth-brand h1{font-size:clamp(2.8rem,5vw,5rem);line-height:.95;letter-spacing:-.06em;margin:.4em 0}.auth-brand>p{color:#a8c1b8;line-height:1.7;max-width:650px}.trust-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:24px}.trust-row span{border:1px solid rgba(97,230,178,.25);border-radius:999px;padding:7px 10px;color:var(--accent2);font-size:.76rem}.auth-card{padding:clamp(30px,5vw,62px);background:#0b1916;display:flex;flex-direction:column;justify-content:center}.auth-card h2{font-size:2rem;margin:.15em 0}.auth-card .button{width:100%;margin-top:4px}@media(max-width:980px){.metrics{grid-template-columns:repeat(2,1fr)}.grid.two,.auth-shell{grid-template-columns:1fr}.auth-brand{min-height:420px}.form-grid.three{grid-template-columns:1fr}.hero{align-items:flex-start;flex-direction:column}.status-card{min-width:0;width:100%}}@media(max-width:650px){.shell{width:min(100% - 22px,1450px);padding-top:30px}.topbar{padding:12px}.user-name,.role-pill{display:none}.metrics,.form-grid,.grid.two{grid-template-columns:1fr}.panel{padding:17px}.auth-page{padding:10px}.auth-shell{border-radius:18px}.auth-brand{padding:34px 24px;min-height:360px}.auth-card{padding:34px 24px}.audit-list>div{grid-template-columns:1fr}.hero h1{font-size:2.7rem}footer{flex-direction:column;gap:10px}} +.key-import{margin-top:18px;border-top:1px solid rgba(255,255,255,.08);padding-top:14px}.key-import summary{cursor:pointer;color:var(--muted);font-weight:750;margin-bottom:12px} diff --git a/web/templates/login.html b/web/templates/login.html new file mode 100644 index 0000000..0c67eac --- /dev/null +++ b/web/templates/login.html @@ -0,0 +1,35 @@ + + + + + + Login · {{.Brand}} + + + +
+
+ +

License infrastructure

+

Eine zentrale Autorität für jede Produktlizenz.

+

Offline signierte Lizenzen, kurzlebige Online-Leases und getrennte Portale für Management, Reseller und Kunden.

+
Ed25519AES-256-GCMHybrid offline
+
+
+
+

Geschützter Bereich

+

Anmelden

+

Verwende deinen persönlichen Account. Zugriffe werden protokolliert.

+
+ {{if .Error}}
{{.Error}}
{{end}} +
+ + + + +
+

Sessions sind HttpOnly, SameSite=Strict und zeitlich begrenzt.

+
+
+ + diff --git a/web/templates/portal.html b/web/templates/portal.html new file mode 100644 index 0000000..c5bac7c --- /dev/null +++ b/web/templates/portal.html @@ -0,0 +1,96 @@ + + + + + + {{.PortalTitle}} · {{.Brand}} + + + +
+ L{{.Brand}} +
{{.RoleLabel}}{{.User.DisplayName}}
+
+
+
+

{{.PortalTitle}}

{{.Headline}}

{{.Subline}}

+
{{if .KeysReady}}Signaturdienst bereit{{else}}Initialisierung erforderlich{{end}}{{if .KeysReady}}Private Keys verschlüsselt gespeichert{{else}}Schlüsselpaare noch nicht erzeugt{{end}}
+
+ {{if .Message}}
{{.Message}}
{{end}} + {{if .Error}}
{{.Error}}
{{end}} + +
+
Aktive Lizenzen{{.ActiveCount}}im sichtbaren Mandantenbereich
+
Auslaufend{{.ExpiringCount}}innerhalb von 30 Tagen
+
Widerrufen{{.RevokedCount}}sofort serverseitig gesperrt
+
Validierungs-APIOnline/api/v1/licenses/validate
+
+ + {{if eq .User.Role "admin"}} +
+
+

Root of trust

Schlüsselverwaltung

Write-once
+ {{if .KeysReady}} +

Die Schlüsselpaare sind erzeugt. Eine erneute Generierung ist in Oberfläche und Backend gesperrt.

+
Issuer Key ID
{{.Keys.IssuerKeyID}}
Lease Key ID
{{.Keys.LeaseKeyID}}
Erstellt
{{formatTime .Keys.CreatedAt}}
+ + {{else}} +

Erzeuge genau einmal die langfristige Issuer- und die kurzlebige Lease-Autorität. Private Schlüssel werden vor dem Speichern mit dem Master-Key verschlüsselt.

+
+
Bestehende Schlüsselpaare sicher importieren
+ {{end}} +
+
+

Identity & access

Benutzer anlegen

+
+
{{range .Users}}
{{initial .DisplayName}}
{{.DisplayName}}@{{.Username}} · {{.Role}}
{{end}}
+
+
+ {{end}} + + {{if eq .User.Role "reseller"}} +
+

Customer onboarding

Kundenportal anlegen

Mandantentrennung
+

Der neue Kunde wird deinem Reseller-Account fest zugeordnet und ist für andere Reseller nicht sichtbar.

+
+
+ {{end}} + +
+

Account security

Passwort ändern

Session reset
+
+
+ + {{if or (eq .User.Role "admin") (eq .User.Role "reseller")}} +
+

License composer

Neue Lizenz ausstellen

signiert
+ {{if .KeysReady}} +
+
+
+
+
+
+ +
+ {{else}}
Ein Administrator muss zuerst die Schlüsselpaare initialisieren.
{{end}} +
+ {{end}} + +
+

Portfolio

{{if eq .User.Role "customer"}}Meine Lizenzen{{else}}Lizenzbestand{{end}}

{{len .Licenses}} Einträge
+
+ {{range .Licenses}}{{else}}{{end}} +
LizenzKunde / ProduktModusStatusGültig bisAktion
{{.LicenseID}}{{.Edition}}{{.Customer}}{{.Product}}{{.Mode}}{{if .Revoked}}Widerrufen{{else}}Aktiv{{end}}{{formatTime .ExpiresAt}}
Token{{if or (eq $.User.Role "admin") (and (eq $.User.Role "reseller") (eq .IssuedByUserID $.User.ID))}}
{{end}}
Noch keine Lizenzen vorhanden.
+
+ + {{if eq .User.Role "admin"}} +
+

Audit trail

Letzte Sicherheitsereignisse

+
{{range .Audit}}
{{.Action}}{{.Target}}{{.Detail}}
{{else}}
Noch keine Ereignisse.
{{end}}
+
+ {{end}} +
+ + + diff --git a/web/templates/token.html b/web/templates/token.html new file mode 100644 index 0000000..31c286f --- /dev/null +++ b/web/templates/token.html @@ -0,0 +1,6 @@ +Lizenz-Token · {{.Brand}}
L{{.Brand}}Zurück

Credential delivery

{{.License.LicenseID}}

Dieser Token enthält keine privaten Schlüssel. Er kann als Secret im Zielsystem hinterlegt werden.

Client-Konfiguration

{{.License.Mode}}
Die Server-URL ist zusätzlich signiert im Token hinterlegt. Moderne Clients erkennen sie automatisch; ENV überschreibt die automatische Erkennung.