From f60ca88a0974218f5b648b33735262d7e5d21b8c Mon Sep 17 00:00:00 2001 From: Dejan R Date: Mon, 20 Apr 2026 16:25:52 +0200 Subject: [PATCH] added activator subfolder --- activator/README.md | 125 ++++++++++ activator/go.mod | 3 + activator/main.go | 564 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 692 insertions(+) create mode 100644 activator/README.md create mode 100644 activator/go.mod create mode 100644 activator/main.go diff --git a/activator/README.md b/activator/README.md new file mode 100644 index 0000000..cb3a02f --- /dev/null +++ b/activator/README.md @@ -0,0 +1,125 @@ +# License Activation App + +Small offline Go web app for generating signed licenses for the protected `force_monitor` application. + +## What this app does + +- generates Ed25519 keypairs +- parses activation request JSON copied from the target machine +- creates signed license JSON bound to that machine fingerprint +- shows the public key that must be configured in the protected app + +## Important deployment answer + +**Recommended setup:** + +- Run the **activator on your laptop** or on a secure internal office PC. +- Run the **protected app on the machine PC**. +- **Do not** ship the activator together with the machine app. +- **Do not** place the private signing key on the customer machine. + +### Why + +The protected machine should only contain the **public key** so it can verify licenses. +The activator should keep the **private key** secret, because the private key is what creates valid licenses. +If the private key is copied to the machine, anyone with access to that PC could generate licenses. + +## Recommended workflow + +1. Install the protected app on the target machine. +2. Open the protected app in browser. +3. Copy the activation request JSON from: + - `GET /api/license/request` + - or the activation page if you added one in the UI. +4. Run this activator on your laptop: + ```bash + go run . + ``` +5. Open: + ```text + http://localhost:8090 + ``` +6. Paste the activation request JSON. +7. Generate or load your signing key. +8. Generate the signed license JSON. +9. Copy that license JSON back to the target machine. +10. Activate it in the protected app using: + - `POST /api/license/activate` + - or the protected app activation page. + +## First-time key setup + +You only need to create the signing keypair once. + +### Option A — generate in the activator UI + +- Click **Generate new keypair** +- Save the private key somewhere safe +- Copy the public key into the protected app config: + +```yaml +license: + public_key_base64: "PASTE_PUBLIC_KEY_HERE" +``` + +### Option B — use environment variable for the private key + +Set this before running the activator: + +### Windows PowerShell + +```powershell +$env:LICENSE_PRIVATE_KEY_BASE64="PASTE_PRIVATE_KEY_HERE" +go run . +``` + +### Linux/macOS shell + +```bash +export LICENSE_PRIVATE_KEY_BASE64="PASTE_PRIVATE_KEY_HERE" +go run . +``` + +Then the UI can generate licenses without pasting the private key each time. + +## Project files + +- `main.go` — activator web app +- `go.mod` — module definition +- `README.md` — usage instructions + +## Run + +```bash +go run . +``` + +Then open: + +```text +http://localhost:8090 +``` + +## Optional environment variables + +- `ACTIVATOR_LISTEN_ADDR` — default `:8090` +- `ACTIVATOR_DEFAULT_PRODUCT` — default `force_monitor` +- `LICENSE_PRIVATE_KEY_BASE64` — private signing key + +Example: + +```powershell +$env:ACTIVATOR_LISTEN_ADDR=":8095" +$env:ACTIVATOR_DEFAULT_PRODUCT="force_monitor" +$env:LICENSE_PRIVATE_KEY_BASE64="PASTE_PRIVATE_KEY_HERE" +go run . +``` + +## Practical recommendation + +For your case, the safest and cleanest model is: + +- laptop/office PC = **license generator / activator** +- machine PC = **protected runtime app only** + +That way you can activate many customer machines without exposing your private signing key. diff --git a/activator/go.mod b/activator/go.mod new file mode 100644 index 0000000..4672031 --- /dev/null +++ b/activator/go.mod @@ -0,0 +1,3 @@ +module licence-activation-app + +go 1.22 diff --git a/activator/main.go b/activator/main.go new file mode 100644 index 0000000..165b60f --- /dev/null +++ b/activator/main.go @@ -0,0 +1,564 @@ +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "html/template" + "log" + "net/http" + "os" + "strings" + "time" +) + +const appVersion = "1.0.0" + +type ActivationRequest struct { + App string `json:"app"` + Version string `json:"version"` + GeneratedAt string `json:"generated_at"` + Hostname string `json:"hostname"` + Platform string `json:"platform"` + Fingerprint string `json:"fingerprint"` + FingerprintShort string `json:"fingerprint_short"` + Components []string `json:"components"` +} + +type SignedLicense struct { + App string `json:"app"` + LicenseID string `json:"license_id"` + Customer string `json:"customer"` + Fingerprint string `json:"fingerprint"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at,omitempty"` + Features []string `json:"features,omitempty"` + Signature string `json:"signature"` +} + +type licensePayload struct { + App string `json:"app"` + LicenseID string `json:"license_id"` + Customer string `json:"customer"` + Fingerprint string `json:"fingerprint"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at,omitempty"` + Features []string `json:"features,omitempty"` +} + +type keyPairResponse struct { + PublicKeyBase64 string `json:"public_key_base64"` + PrivateKeyBase64 string `json:"private_key_base64"` +} + +type generateLicenseRequest struct { + PrivateKeyBase64 string `json:"private_key_base64"` + App string `json:"app"` + LicenseID string `json:"license_id"` + Customer string `json:"customer"` + Fingerprint string `json:"fingerprint"` + IssuedAt string `json:"issued_at"` + ExpiresAt string `json:"expires_at"` + Features string `json:"features"` +} + +type generateLicenseResponse struct { + License SignedLicense `json:"license"` + LicenseJSON string `json:"license_json"` + PublicKeyBase64 string `json:"public_key_base64"` + PayloadJSON string `json:"payload_json"` +} + +type parseRequestResponse struct { + Request ActivationRequest `json:"request"` +} + +var pageTmpl = template.Must(template.New("index").Parse(indexHTML)) + +func main() { + mux := http.NewServeMux() + mux.HandleFunc("/", handleIndex) + mux.HandleFunc("/api/health", handleHealth) + mux.HandleFunc("/api/keypair", handleKeypair) + mux.HandleFunc("/api/request/parse", handleRequestParse) + mux.HandleFunc("/api/license/generate", handleGenerateLicense) + + addr := envOrDefault("ACTIVATOR_LISTEN_ADDR", ":8090") + log.Printf("License activator %s listening on http://localhost%s", appVersion, addr) + if err := http.ListenAndServe(addr, loggingMiddleware(mux)); err != nil { + log.Fatal(err) + } +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + data := struct { + Version string + DefaultProduct string + EnvPrivateKeyPresent bool + }{ + Version: appVersion, + DefaultProduct: envOrDefault("ACTIVATOR_DEFAULT_PRODUCT", "force_monitor"), + EnvPrivateKeyPresent: strings.TrimSpace(os.Getenv("LICENSE_PRIVATE_KEY_BASE64")) != "", + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := pageTmpl.Execute(w, data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + if !allowMethod(w, r, http.MethodGet) { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "version": appVersion, + "time": time.Now().UTC().Format(time.RFC3339), + }) +} + +func handleKeypair(w http.ResponseWriter, r *http.Request) { + if !allowMethod(w, r, http.MethodPost) { + return + } + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + writeJSON(w, http.StatusOK, keyPairResponse{ + PublicKeyBase64: base64.StdEncoding.EncodeToString(pub), + PrivateKeyBase64: base64.StdEncoding.EncodeToString(priv), + }) +} + +func handleRequestParse(w http.ResponseWriter, r *http.Request) { + if !allowMethod(w, r, http.MethodPost) { + return + } + var body struct { + Text string `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("invalid json: %w", err)) + return + } + var req ActivationRequest + if err := json.Unmarshal([]byte(strings.TrimSpace(body.Text)), &req); err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("invalid activation request json: %w", err)) + return + } + writeJSON(w, http.StatusOK, parseRequestResponse{Request: req}) +} + +func handleGenerateLicense(w http.ResponseWriter, r *http.Request) { + if !allowMethod(w, r, http.MethodPost) { + return + } + var req generateLicenseRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, fmt.Errorf("invalid json: %w", err)) + return + } + + privateKeyBase64 := strings.TrimSpace(req.PrivateKeyBase64) + if privateKeyBase64 == "" { + privateKeyBase64 = strings.TrimSpace(os.Getenv("LICENSE_PRIVATE_KEY_BASE64")) + } + if privateKeyBase64 == "" { + writeError(w, http.StatusBadRequest, errors.New("private_key_base64 is required or set LICENSE_PRIVATE_KEY_BASE64")) + return + } + + lic, payloadJSON, pubKey, err := buildSignedLicense(req, privateKeyBase64) + if err != nil { + writeError(w, http.StatusBadRequest, err) + return + } + + b, err := json.MarshalIndent(lic, "", " ") + if err != nil { + writeError(w, http.StatusInternalServerError, err) + return + } + + writeJSON(w, http.StatusOK, generateLicenseResponse{ + License: lic, + LicenseJSON: string(b), + PublicKeyBase64: pubKey, + PayloadJSON: payloadJSON, + }) +} + +func buildSignedLicense(req generateLicenseRequest, privateKeyBase64 string) (SignedLicense, string, string, error) { + app := strings.TrimSpace(req.App) + if app == "" { + app = envOrDefault("ACTIVATOR_DEFAULT_PRODUCT", "force_monitor") + } + lic := SignedLicense{ + App: app, + LicenseID: strings.TrimSpace(req.LicenseID), + Customer: strings.TrimSpace(req.Customer), + Fingerprint: normalizeFingerprint(req.Fingerprint), + IssuedAt: strings.TrimSpace(req.IssuedAt), + ExpiresAt: strings.TrimSpace(req.ExpiresAt), + Features: parseFeatures(req.Features), + } + + if lic.LicenseID == "" { + lic.LicenseID = "LIC-" + time.Now().UTC().Format("20060102-150405") + } + if lic.Fingerprint == "" { + return SignedLicense{}, "", "", errors.New("fingerprint is required") + } + if lic.IssuedAt == "" { + lic.IssuedAt = time.Now().UTC().Format(time.RFC3339) + } + if _, err := time.Parse(time.RFC3339, lic.IssuedAt); err != nil { + return SignedLicense{}, "", "", fmt.Errorf("issued_at must be RFC3339, example 2026-04-20T12:00:00Z") + } + if lic.ExpiresAt != "" { + if _, err := time.Parse(time.RFC3339, lic.ExpiresAt); err != nil { + return SignedLicense{}, "", "", fmt.Errorf("expires_at must be RFC3339, example 2027-04-20T00:00:00Z") + } + } + + privRaw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(privateKeyBase64)) + if err != nil { + return SignedLicense{}, "", "", fmt.Errorf("decode private key: %w", err) + } + if len(privRaw) != ed25519.PrivateKeySize { + return SignedLicense{}, "", "", fmt.Errorf("invalid private key size: got %d want %d", len(privRaw), ed25519.PrivateKeySize) + } + priv := ed25519.PrivateKey(privRaw) + + payload := licensePayload{ + App: lic.App, + LicenseID: lic.LicenseID, + Customer: lic.Customer, + Fingerprint: lic.Fingerprint, + IssuedAt: lic.IssuedAt, + ExpiresAt: lic.ExpiresAt, + Features: lic.Features, + } + payloadBytes, err := json.Marshal(payload) + if err != nil { + return SignedLicense{}, "", "", fmt.Errorf("marshal payload: %w", err) + } + + sig := ed25519.Sign(priv, payloadBytes) + lic.Signature = base64.StdEncoding.EncodeToString(sig) + pub := priv.Public().(ed25519.PublicKey) + return lic, string(payloadBytes), base64.StdEncoding.EncodeToString(pub), nil +} + +func parseFeatures(raw string) []string { + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + seen := map[string]struct{}{} + for _, p := range parts { + v := strings.TrimSpace(p) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +func normalizeFingerprint(v string) string { + v = strings.TrimSpace(strings.ToUpper(v)) + v = strings.ReplaceAll(v, "-", "") + v = strings.ReplaceAll(v, " ", "") + return v +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, err error) { + writeJSON(w, status, map[string]string{"error": err.Error()}) +} + +func allowMethod(w http.ResponseWriter, r *http.Request, method string) bool { + if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.WriteHeader(http.StatusNoContent) + return false + } + if r.Method != method { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return false + } + return true +} + +func envOrDefault(key, def string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return def +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + }) +} + +const indexHTML = ` + + + + + License Activator + + + +
+
+
+
License Activator v{{.Version}}
+

Offline License Generator

+

Create Ed25519 keypairs, parse activation requests from the machine, and generate signed license JSON for your protected Go app.

+
+
+

Important

+

Keep this activator private. Do not ship your private signing key with the customer machine. Only the public key belongs in the protected app config.

+
Env private key loaded: {{if .EnvPrivateKeyPresent}}yes{{else}}no{{end}}
+
Default product code: {{.DefaultProduct}}
+
+
+ +
+
+
+

1. Activation request from machine

+ + +
+ + +
+
Paste the JSON from /api/license/request of the protected machine, then parse it.
+
+ +
+

2. License data

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

3. Signing key

+ + +
+ + +
+
The generated public key must be copied into the protected app config as license.public_key_base64.
+
+
+ +
+
+

Generated keypair

+ + + + +
+ +
+

Signed license JSON

+ + + + +
Generate a license and copy the JSON into the protected machine activation page.
+
+
+
+
+ + + +`