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

License infrastructure

+

Eine zentrale Autorität für jede Produktlizenz.

+

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

+
Ed25519AES-256-GCMHybrid offline
+
+
+
+

Geschützter Bereich

+

Anmelden

+

Verwende deinen persönlichen Account. Zugriffe werden protokolliert.

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

Sessions sind HttpOnly, SameSite=Strict und zeitlich begrenzt.

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

{{.PortalTitle}}

{{.Headline}}

{{.Subline}}

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

Root of trust

Schlüsselverwaltung

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

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

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

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

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

Identity & access

Benutzer anlegen

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

Customer onboarding

Kundenportal anlegen

Mandantentrennung
+

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

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

License composer

Neue Lizenz ausstellen

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

Portfolio

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

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

Audit trail

Letzte Sicherheitsereignisse

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

Credential delivery

{{.License.LicenseID}}

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

Client-Konfiguration

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