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}}
+
+
+
+
+
+ L
+ 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.
+ Ed25519 AES-256-GCM Hybrid 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}}
+
+
+
+
+
+
+ {{.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-API Online /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}}
+ Öffentlicher Trust Store
+ {{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
+ Lizenz Kunde / Produkt Modus Status Gültig bis Aktion
+ {{range .Licenses}}{{.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))}}
{{if .Revoked}}Aktivieren{{else}}Sperren{{end}} {{end}}
{{else}}Noch keine Lizenzen vorhanden.
{{end}}
+
+
+
+ {{if eq .User.Role "admin"}}
+
+ Audit trail
Letzte Sicherheitsereignisse
+ {{range .Audit}}
{{formatTime .CreatedAt}} {{.Action}} {{.Target}} {{.Detail}}
{{else}}
Noch keine Ereignisse.
{{end}}
+
+ {{end}}
+
+{{.Brand}} Ed25519 · AES-256-GCM · CSRF · Audit
+
+
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}} Credential delivery
{{.License.LicenseID}} Dieser Token enthält keine privaten Schlüssel. Er kann als Secret im Zielsystem hinterlegt werden.
Client-Konfiguration {{.License.Mode}} EnvironmentLICENSE_TOKEN={{.Token}}
+LICENSE_MODE={{.License.Mode}}
+LICENSE_SERVER_URL={{.License.ServerURL}}
+LICENSE_CACHE_FILE=./data/license-lease.json
+LICENSE_REFRESH_INTERVAL=15m
+LICENSE_REQUEST_TIMEOUT=5s Signierter Lizenz-Token{{.Token}} Die Server-URL ist zusätzlich signiert im Token hinterlegt. Moderne Clients erkennen sie automatisch; ENV überschreibt die automatische Erkennung.