This commit is contained in:
2026-07-20 21:41:51 +02:00
parent 355266df01
commit 91081f6501
35 changed files with 4203 additions and 1 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
.env
data
bin
*.log

26
.env.example Normal file
View File

@@ -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

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.env
data/
bin/
*.log

16
Dockerfile Normal file
View File

@@ -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"]

21
LICENSE Normal file
View File

@@ -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.

19
Makefile Normal file
View File

@@ -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 .

162
README.md
View File

@@ -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.

10
SECURITY.md Normal file
View File

@@ -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.

137
cmd/server/main.go Normal file
View File

@@ -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 ""
}

30
compose.yaml Normal file
View File

@@ -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:

23
docs/ARCHITECTURE.md Normal file
View File

@@ -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.

14
docs/MIGRATION.md Normal file
View File

@@ -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.

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/b1tsblog/license-platform
go 1.26

129
internal/platform/auth.go Normal file
View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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() }

View File

@@ -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")
}
}

View File

@@ -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)
}

View File

@@ -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
}

705
internal/platform/server.go Normal file
View File

@@ -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())
})
}

353
internal/platform/store.go Normal file
View File

@@ -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 &copy, 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
}

252
openapi.yaml Normal file
View File

@@ -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}

3
pkg/licensekit/doc.go Normal file
View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}

35
run.ps1 Normal file
View File

@@ -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

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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

8
web/embed.go Normal file
View File

@@ -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

2
web/static/app.css Normal file

File diff suppressed because one or more lines are too long

35
web/templates/login.html Normal file
View File

@@ -0,0 +1,35 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Login · {{.Brand}}</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body class="auth-page">
<main class="auth-shell">
<section class="auth-brand">
<div class="brand-mark" aria-hidden="true">L</div>
<p class="eyebrow">License infrastructure</p>
<h1>Eine zentrale Autorität für jede Produktlizenz.</h1>
<p>Offline signierte Lizenzen, kurzlebige Online-Leases und getrennte Portale für Management, Reseller und Kunden.</p>
<div class="trust-row"><span>Ed25519</span><span>AES-256-GCM</span><span>Hybrid offline</span></div>
</section>
<section class="auth-card">
<div>
<p class="eyebrow">Geschützter Bereich</p>
<h2>Anmelden</h2>
<p class="muted">Verwende deinen persönlichen Account. Zugriffe werden protokolliert.</p>
</div>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<form method="post" action="/login" class="stack">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Benutzername<input name="username" autocomplete="username" required autofocus></label>
<label>Passwort<input type="password" name="password" autocomplete="current-password" required></label>
<button class="button primary" type="submit">Sicher anmelden</button>
</form>
<p class="micro">Sessions sind HttpOnly, SameSite=Strict und zeitlich begrenzt.</p>
</section>
</main>
</body>
</html>

96
web/templates/portal.html Normal file
View File

@@ -0,0 +1,96 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.PortalTitle}} · {{.Brand}}</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<header class="topbar">
<a class="brand" href="/"><span class="brand-mark small">L</span><span>{{.Brand}}</span></a>
<div class="top-actions"><span class="role-pill">{{.RoleLabel}}</span><span class="user-name">{{.User.DisplayName}}</span><form method="post" action="/logout"><input type="hidden" name="csrf" value="{{.CSRF}}"><button class="button ghost" type="submit">Abmelden</button></form></div>
</header>
<main class="shell">
<section class="hero">
<div><p class="eyebrow">{{.PortalTitle}}</p><h1>{{.Headline}}</h1><p>{{.Subline}}</p></div>
<div class="status-card"><span class="status-dot {{if .KeysReady}}ok{{else}}warn{{end}}"></span><div><strong>{{if .KeysReady}}Signaturdienst bereit{{else}}Initialisierung erforderlich{{end}}</strong><small>{{if .KeysReady}}Private Keys verschlüsselt gespeichert{{else}}Schlüsselpaare noch nicht erzeugt{{end}}</small></div></div>
</section>
{{if .Message}}<div class="alert alert-success">{{.Message}}</div>{{end}}
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<section class="metrics">
<article><span>Aktive Lizenzen</span><strong>{{.ActiveCount}}</strong><small>im sichtbaren Mandantenbereich</small></article>
<article><span>Auslaufend</span><strong>{{.ExpiringCount}}</strong><small>innerhalb von 30 Tagen</small></article>
<article><span>Widerrufen</span><strong>{{.RevokedCount}}</strong><small>sofort serverseitig gesperrt</small></article>
<article><span>Validierungs-API</span><strong>Online</strong><small>/api/v1/licenses/validate</small></article>
</section>
{{if eq .User.Role "admin"}}
<section class="grid two">
<article class="panel accent-panel">
<div class="panel-head"><div><p class="eyebrow">Root of trust</p><h2>Schlüsselverwaltung</h2></div><span class="badge">Write-once</span></div>
{{if .KeysReady}}
<p class="muted">Die Schlüsselpaare sind erzeugt. Eine erneute Generierung ist in Oberfläche und Backend gesperrt.</p>
<dl class="key-list"><div><dt>Issuer Key ID</dt><dd>{{.Keys.IssuerKeyID}}</dd></div><div><dt>Lease Key ID</dt><dd>{{.Keys.LeaseKeyID}}</dd></div><div><dt>Erstellt</dt><dd>{{formatTime .Keys.CreatedAt}}</dd></div></dl>
<label>Öffentlicher Trust Store<textarea readonly rows="7">{{.TrustStoreJSON}}</textarea></label>
{{else}}
<p>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.</p>
<form method="post" action="/admin/keys/generate" class="stack compact"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Issuer Key ID<input name="issuerKeyId" value="issuer-2026" required></label><label>Lease Key ID<input name="leaseKeyId" value="lease-2026" required></label><button class="button primary" type="submit">Schlüsselpaare einmalig erzeugen</button></form>
<details class="key-import"><summary>Bestehende Schlüsselpaare sicher importieren</summary><form method="post" action="/admin/keys/import" class="stack compact"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="form-grid"><label>Issuer Key ID<input name="issuerKeyId" required></label><label>Lease Key ID<input name="leaseKeyId" required></label></div><label>Issuer Private Key<input type="password" name="issuerPrivate" autocomplete="off" required></label><label>Lease Private Key<input type="password" name="leasePrivate" autocomplete="off" required></label><button class="button ghost" type="submit">Einmalig importieren und sperren</button></form></details>
{{end}}
</article>
<article class="panel">
<div class="panel-head"><div><p class="eyebrow">Identity & access</p><h2>Benutzer anlegen</h2></div></div>
<form method="post" action="/admin/users" class="stack compact"><input type="hidden" name="csrf" value="{{.CSRF}}"><div class="form-grid"><label>Benutzername<input name="username" required></label><label>Anzeigename<input name="displayName" required></label></div><div class="form-grid"><label>Rolle<select name="role"><option value="reseller">Reseller / Autor</option><option value="customer">Kunde / Client</option><option value="admin">Admin / Management</option></select></label><label>Initialpasswort<input type="password" name="password" minlength="12" required></label></div><label>Reseller-Zuordnung (nur für Kunden)<select name="resellerUserId"><option value="">Direktkunde / keine Zuordnung</option>{{range .Resellers}}<option value="{{.ID}}">{{.DisplayName}} (@{{.Username}})</option>{{end}}</select></label><button class="button secondary" type="submit">Account erstellen</button></form>
<div class="user-list">{{range .Users}}<div><span class="avatar">{{initial .DisplayName}}</span><div><strong>{{.DisplayName}}</strong><small>@{{.Username}} · {{.Role}}</small></div></div>{{end}}</div>
</article>
</section>
{{end}}
{{if eq .User.Role "reseller"}}
<section class="panel">
<div class="panel-head"><div><p class="eyebrow">Customer onboarding</p><h2>Kundenportal anlegen</h2></div><span class="badge">Mandantentrennung</span></div>
<p class="muted">Der neue Kunde wird deinem Reseller-Account fest zugeordnet und ist für andere Reseller nicht sichtbar.</p>
<form method="post" action="/reseller/customers" class="form-grid three"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Benutzername<input name="username" required></label><label>Anzeigename<input name="displayName" required></label><label>Initialpasswort<input type="password" name="password" minlength="12" required></label><button class="button secondary" type="submit">Kundenportal erstellen</button></form>
</section>
{{end}}
<section class="panel account-panel">
<div class="panel-head"><div><p class="eyebrow">Account security</p><h2>Passwort ändern</h2></div><span class="badge">Session reset</span></div>
<form method="post" action="/account/password" class="form-grid three"><input type="hidden" name="csrf" value="{{.CSRF}}"><label>Aktuelles Passwort<input type="password" name="currentPassword" autocomplete="current-password" required></label><label>Neues Passwort<input type="password" name="newPassword" minlength="12" autocomplete="new-password" required></label><label>&nbsp;<button class="button ghost" type="submit">Passwort aktualisieren</button></label></form>
</section>
{{if or (eq .User.Role "admin") (eq .User.Role "reseller")}}
<section class="panel">
<div class="panel-head"><div><p class="eyebrow">License composer</p><h2>Neue Lizenz ausstellen</h2></div><span class="badge badge-live">signiert</span></div>
{{if .KeysReady}}
<form method="post" action="/licenses" class="stack"><input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="form-grid three"><label>Kunden-Account<select name="customerUserId"><option value="">Ohne Portal-Zuordnung</option>{{range .Customers}}<option value="{{.ID}}">{{.DisplayName}} (@{{.Username}})</option>{{end}}</select></label><label>Kundenname<input name="customer" required></label><label>Produkt-ID<input name="product" placeholder="my-product" required></label></div>
<div class="form-grid three"><label>Edition<input name="edition" placeholder="professional" required></label><label>Modus<select name="mode"><option value="hybrid">Hybrid offline</option><option value="online">Online erforderlich</option><option value="offline">Vollständig offline</option></select></label><label>Gültigkeit (Tage)<input type="number" name="days" min="1" max="3650" value="365" required></label></div>
<div class="form-grid"><label>Features<textarea name="features" placeholder="advanced_export, white_label"></textarea></label><label>Limits<textarea name="limits" placeholder="users=50&#10;sites=10"></textarea></label></div>
<div class="form-grid"><label>Domains<input name="domains" value="*" placeholder="example.org, *.example.org"></label><label>Instanz-IDs<input name="instances" placeholder="optional"></label></div>
<div class="form-grid three"><label>Lease TTL (Minuten)<input type="number" name="leaseMinutes" min="1" max="1440" value="60"></label><label>Offline Grace (Stunden)<input type="number" name="graceHours" min="0" max="8760" value="72"></label><label>Server-URL<input value="{{.PublicURL}}" readonly></label></div>
<button class="button primary" type="submit">Lizenz signieren und registrieren</button>
</form>
{{else}}<div class="empty">Ein Administrator muss zuerst die Schlüsselpaare initialisieren.</div>{{end}}
</section>
{{end}}
<section class="panel">
<div class="panel-head"><div><p class="eyebrow">Portfolio</p><h2>{{if eq .User.Role "customer"}}Meine Lizenzen{{else}}Lizenzbestand{{end}}</h2></div><span class="badge">{{len .Licenses}} Einträge</span></div>
<div class="table-wrap"><table><thead><tr><th>Lizenz</th><th>Kunde / Produkt</th><th>Modus</th><th>Status</th><th>Gültig bis</th><th>Aktion</th></tr></thead><tbody>
{{range .Licenses}}<tr><td><strong>{{.LicenseID}}</strong><small>{{.Edition}}</small></td><td>{{.Customer}}<small>{{.Product}}</small></td><td><span class="mode">{{.Mode}}</span></td><td>{{if .Revoked}}<span class="status revoked">Widerrufen</span>{{else}}<span class="status active">Aktiv</span>{{end}}</td><td>{{formatTime .ExpiresAt}}</td><td><div class="row-actions"><a class="button tiny ghost" href="/licenses/{{.LicenseID}}/token">Token</a>{{if or (eq $.User.Role "admin") (and (eq $.User.Role "reseller") (eq .IssuedByUserID $.User.ID))}}<form method="post" action="/licenses/{{.LicenseID}}/{{if .Revoked}}restore{{else}}revoke{{end}}"><input type="hidden" name="csrf" value="{{$.CSRF}}"><button class="button tiny {{if .Revoked}}secondary{{else}}danger{{end}}" type="submit">{{if .Revoked}}Aktivieren{{else}}Sperren{{end}}</button></form>{{end}}</div></td></tr>{{else}}<tr><td colspan="6"><div class="empty">Noch keine Lizenzen vorhanden.</div></td></tr>{{end}}
</tbody></table></div>
</section>
{{if eq .User.Role "admin"}}
<section class="panel">
<div class="panel-head"><div><p class="eyebrow">Audit trail</p><h2>Letzte Sicherheitsereignisse</h2></div></div>
<div class="audit-list">{{range .Audit}}<div><time>{{formatTime .CreatedAt}}</time><strong>{{.Action}}</strong><span>{{.Target}}</span><small>{{.Detail}}</small></div>{{else}}<div class="empty">Noch keine Ereignisse.</div>{{end}}</div>
</section>
{{end}}
</main>
<footer><span>{{.Brand}}</span><span>Ed25519 · AES-256-GCM · CSRF · Audit</span></footer>
</body>
</html>

6
web/templates/token.html Normal file
View File

@@ -0,0 +1,6 @@
<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Lizenz-Token · {{.Brand}}</title><link rel="stylesheet" href="/assets/app.css"></head><body><header class="topbar"><a class="brand" href="/"><span class="brand-mark small">L</span><span>{{.Brand}}</span></a><a class="button ghost" href="/">Zurück</a></header><main class="shell narrow"><section class="hero"><div><p class="eyebrow">Credential delivery</p><h1>{{.License.LicenseID}}</h1><p>Dieser Token enthält keine privaten Schlüssel. Er kann als Secret im Zielsystem hinterlegt werden.</p></div></section><section class="panel"><div class="panel-head"><div><h2>Client-Konfiguration</h2></div><span class="badge">{{.License.Mode}}</span></div><label>Environment<textarea rows="8" readonly>LICENSE_TOKEN={{.Token}}
LICENSE_MODE={{.License.Mode}}
LICENSE_SERVER_URL={{.License.ServerURL}}
LICENSE_CACHE_FILE=./data/license-lease.json
LICENSE_REFRESH_INTERVAL=15m
LICENSE_REQUEST_TIMEOUT=5s</textarea></label><label>Signierter Lizenz-Token<textarea rows="12" readonly>{{.Token}}</textarea></label><div class="alert">Die Server-URL ist zusätzlich signiert im Token hinterlegt. Moderne Clients erkennen sie automatisch; ENV überschreibt die automatische Erkennung.</div></section></main></body></html>