feat(license): Lizenz-System mit Ed25519-Verify gegen license.netcell-it.com
Portiert mail-gateway/internal/license (Verify, Cache, Trial, Signature) + DB-Mirror (internal/services/license) + REST-Handler (status/verify/key/clear) + UI-Page /license (Activate, Status, Limits, Features, Re-verify) + <LicenseBanner /> neben UpdateBanner (trial-expiring, expired, verify-failed) + Scheduler: täglich Re-verify (24h-Tick) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
internal/license/doc.go
Normal file
5
internal/license/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
// Package license implements the NetCell Licensing client (license.netcell-it.com)
|
||||
// with Ed25519 signature verification, KeyDB-lock based leader election (one
|
||||
// verify per cluster/day), shared cache in KeyDB, and a 7-day trial fallback.
|
||||
// Pattern adopted wholesale from netcell-webpanel/docs/licensing-integration.md.
|
||||
package license
|
||||
78
internal/license/keystore.go
Normal file
78
internal/license/keystore.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// KeyStore holds the per-node license key on disk. Every node in a
|
||||
// cluster runs its own verification against license.netcell-it.com
|
||||
// with its own fingerprint + key, so the file lives under
|
||||
// /var/lib/edgeguard/ (node-local, never replicated via the config
|
||||
// fan-out).
|
||||
//
|
||||
// Shape on disk: a single-line text file, leading/trailing whitespace
|
||||
// stripped. Mode 0600, owner nmg so a compromised read of a
|
||||
// checked-out working copy never leaks the key.
|
||||
type KeyStore struct {
|
||||
Path string
|
||||
|
||||
// cached holds the last-loaded key so Get() is cheap; Reload()
|
||||
// flushes it.
|
||||
cached atomic.Value // string
|
||||
}
|
||||
|
||||
const DefaultKeyStorePath = "/var/lib/edgeguard/license_key"
|
||||
|
||||
// NewKeyStore returns a store writing to /var/lib/edgeguard/license_key.
|
||||
func NewKeyStore() *KeyStore { return &KeyStore{Path: DefaultKeyStorePath} }
|
||||
|
||||
// Get returns the currently persisted key, or "" when the file is
|
||||
// absent / empty. Never returns an error so callers can happily fall
|
||||
// through to the trial-mode path.
|
||||
func (s *KeyStore) Get() string {
|
||||
if v, ok := s.cached.Load().(string); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
data, err := os.ReadFile(s.Path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
k := strings.TrimSpace(string(data))
|
||||
s.cached.Store(k)
|
||||
return k
|
||||
}
|
||||
|
||||
// Save writes the key atomically (tmp + rename) with mode 0600 and
|
||||
// primes the in-memory cache. Empty key removes the file — that's
|
||||
// how an admin returns the node to trial mode.
|
||||
func (s *KeyStore) Save(key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
dir := filepath.Dir(s.Path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if key == "" {
|
||||
if err := os.Remove(s.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
s.cached.Store("")
|
||||
return nil
|
||||
}
|
||||
tmp := s.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(key+"\n"), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmp, 0o600); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, s.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
s.cached.Store(key)
|
||||
return nil
|
||||
}
|
||||
391
internal/license/license.go
Normal file
391
internal/license/license.go
Normal file
@@ -0,0 +1,391 @@
|
||||
// Package license implements the NetCell MailGuard client for the NetCell
|
||||
// Licensing server (license.netcell-it.com). It verifies a license key
|
||||
// with Ed25519-signed responses, caches the result on disk (24 h TTL),
|
||||
// falls back to a 30-day offline trial when no key is configured, and
|
||||
// protects against clock manipulation via ServerTime drift checks.
|
||||
//
|
||||
// Pattern wholesale adopted from netcell-webpanel/management-api/internal/
|
||||
// middleware/license.go; edgeguard uses `active_domains` (operator-defined domains) as the usage
|
||||
// counter sent to the licensing server (the number of mail domains this
|
||||
// cluster serves) instead of the webpanel's active_sites/active_servers.
|
||||
//
|
||||
// Cluster semantics: only one peer per cluster contacts license.netcell-it.com
|
||||
// at a time — that peer is selected via a KeyDB lock (cluster:license-leader,
|
||||
// 60-s TTL). The result ends up in KeyDB key cluster:license-status so every
|
||||
// peer can read it locally. Leader election lives in internal/cluster; this
|
||||
// package implements the verification + caching mechanics.
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Tunables. Kept package-private constants; override via internal/license/testing.go in tests.
|
||||
const (
|
||||
DefaultServerURL = "https://license.netcell-it.com"
|
||||
DefaultSelfServiceURL = "https://license.netcell-it.com/self-service"
|
||||
|
||||
DefaultCacheDir = "/var/lib/edgeguard"
|
||||
defaultCacheFile = "license.cache"
|
||||
defaultTrialFile = "trial.json"
|
||||
defaultHTTPTimeout = 10 * time.Second
|
||||
CacheMaxAge = 24 * time.Hour
|
||||
VerifyInterval = 24 * time.Hour
|
||||
TrialDuration = 30 * 24 * time.Hour
|
||||
maxClockDrift = 48 * time.Hour
|
||||
maxResponseBytes = 64 * 1024
|
||||
|
||||
// GracePeriod: wenn der Server eine valid:false-Antwort liefert,
|
||||
// behält der Client den letzten valid:true-Cache so lange bei,
|
||||
// solange der nicht älter als GracePeriod ist. Schützt vor
|
||||
// transienten Server-Bugs (z.B. activation_limit_exceeded falsch
|
||||
// gezählt) und kurzen Wartungsfenstern. Der User-sichtbare Effekt:
|
||||
// keine spontane „Lizenz fehlt"-UI-Zustand mid-Session.
|
||||
// Override per Env EDGEGUARD_LICENSE_GRACE_DAYS.
|
||||
GracePeriod = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// Result is the license server response plus a few internal fields.
|
||||
// JSON tags match the wire format from license.netcell-it.com.
|
||||
type Result struct {
|
||||
Valid bool `json:"valid"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Type string `json:"type,omitempty"` // license | trial
|
||||
Status string `json:"status,omitempty"` // active | expired | revoked
|
||||
Product string `json:"product,omitempty"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
GracePeriod bool `json:"grace_period,omitempty"`
|
||||
Features map[string]bool `json:"features,omitempty"`
|
||||
Limits map[string]int64 `json:"limits,omitempty"`
|
||||
ServerTime *time.Time `json:"server_time,omitempty"` // authoritative timestamp (signed)
|
||||
CachedAt *time.Time `json:"cached_at,omitempty"` // set when loaded from local cache
|
||||
}
|
||||
|
||||
// Feature returns true if the given feature flag is enabled in this license.
|
||||
func (r *Result) Feature(name string) bool { return r.Features[name] }
|
||||
|
||||
// Limit returns the numeric limit for a given key, or 0 if not set.
|
||||
func (r *Result) Limit(name string) int64 { return r.Limits[name] }
|
||||
|
||||
// ActiveDomainsFn yields the mail-domain count for usage reporting
|
||||
// to the license server. Optional — nil means send 0.
|
||||
type ActiveDomainsFn func() int64
|
||||
|
||||
// Client performs verification calls. Construct with NewClient.
|
||||
type Client struct {
|
||||
ServerURL string
|
||||
CacheDir string
|
||||
HTTPTimeout time.Duration
|
||||
ActiveDomains ActiveDomainsFn
|
||||
HTTPClient *http.Client
|
||||
SignatureKeys []SignatureKey // verifies the X-Signature header
|
||||
ClockNow func() time.Time
|
||||
OSHostname func() (string, error)
|
||||
ReadMachineID func() ([]byte, error)
|
||||
InterfacesFn func() ([]net.Interface, error)
|
||||
}
|
||||
|
||||
// NewClient returns a client with production defaults.
|
||||
func NewClient() *Client {
|
||||
return &Client{
|
||||
ServerURL: DefaultServerURL,
|
||||
CacheDir: DefaultCacheDir,
|
||||
HTTPTimeout: defaultHTTPTimeout,
|
||||
HTTPClient: &http.Client{Timeout: defaultHTTPTimeout},
|
||||
SignatureKeys: DefaultSigningKeys(),
|
||||
ClockNow: func() time.Time { return time.Now().UTC() },
|
||||
OSHostname: os.Hostname,
|
||||
ReadMachineID: func() ([]byte, error) { return os.ReadFile("/etc/machine-id") },
|
||||
InterfacesFn: net.Interfaces,
|
||||
}
|
||||
}
|
||||
|
||||
// Verify performs a live check against the license server. Non-nil Result
|
||||
// means we got a valid, signed response. On network failure returns an error;
|
||||
// callers should fall back to the cache (LoadCache) or to Trial (see Check).
|
||||
func (c *Client) Verify(key string) (*Result, error) {
|
||||
if key == "" {
|
||||
return nil, errors.New("license key is empty")
|
||||
}
|
||||
fp := c.SystemFingerprint()
|
||||
hostname, _ := c.OSHostname()
|
||||
|
||||
var activeDomains int64
|
||||
if c.ActiveDomains != nil {
|
||||
activeDomains = c.ActiveDomains()
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf(
|
||||
"%s/api/v1/licenses/%s/verify?system_id=%s&system_name=%s&active_domains=%d",
|
||||
c.ServerURL,
|
||||
url.PathEscape(key),
|
||||
url.QueryEscape(fp),
|
||||
url.QueryEscape(hostname),
|
||||
activeDomains,
|
||||
)
|
||||
|
||||
resp, err := c.HTTPClient.Get(endpoint)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("license server unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("license server returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read license response: %w", err)
|
||||
}
|
||||
|
||||
sig := resp.Header.Get("X-Signature")
|
||||
if sig == "" {
|
||||
return nil, errors.New("license server response missing X-Signature header")
|
||||
}
|
||||
if !VerifySignature(c.SignatureKeys, body, sig) {
|
||||
return nil, errors.New("license server response signature invalid")
|
||||
}
|
||||
|
||||
var result Result
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("decode license response: %w", err)
|
||||
}
|
||||
|
||||
if result.ServerTime != nil {
|
||||
drift := c.ClockNow().Sub(*result.ServerTime)
|
||||
if drift < 0 {
|
||||
drift = -drift
|
||||
}
|
||||
if drift > maxClockDrift {
|
||||
return nil, fmt.Errorf("system clock drift %s exceeds %s (check NTP)", drift, maxClockDrift)
|
||||
}
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// Check returns the currently effective license, trying in order:
|
||||
// live verify (valid:true) -> live verify (valid:false) but cached
|
||||
// valid:true within grace period -> local cache (24 h TTL) -> trial
|
||||
// (7 days) -> expired. Never returns an error.
|
||||
//
|
||||
// Grace-Period-Verhalten (1.6.66+):
|
||||
// - Server-Antwort valid:true → SaveCache + return r
|
||||
// - Server-Antwort valid:false → cache laden:
|
||||
// * cache.Valid && cached_at innerhalb GracePeriod → cache nutzen
|
||||
// (typische Ursache: transientes Server-Problem wie
|
||||
// activation_limit_exceeded nach mehreren verify-Calls)
|
||||
// * sonst → server-Antwort durchreichen, cache NICHT überschreiben
|
||||
// - Verify-Fehler (Netzwerk/HTTP) → cache nutzen (wie pre-1.6.66)
|
||||
//
|
||||
// Die SaveCache-Aufrufe werden ausschließlich für valid:true-Results
|
||||
// gemacht. Damit überschreibt eine vorübergehende valid:false-Antwort
|
||||
// niemals einen vorher gespeicherten gültigen Stand. Operator hat 7
|
||||
// Tage Zeit zu reagieren ohne dass die UI sofort Lizenz-Eingabe fordert.
|
||||
func (c *Client) Check(key string) *Result {
|
||||
if key != "" {
|
||||
if r, err := c.Verify(key); err == nil {
|
||||
if r.Valid {
|
||||
_ = c.SaveCache(r)
|
||||
slog.Info("license verified", "product", r.Product, "type", r.Type, "valid", true)
|
||||
return r
|
||||
}
|
||||
// Server says valid:false. Vor wir das durchreichen,
|
||||
// prüfen wir ob ein gültiger Cache existiert der noch
|
||||
// in der Grace-Period liegt — dann nutzen wir den.
|
||||
if cached, cerr := c.LoadCache(); cerr == nil && cached.Valid {
|
||||
age := graceAge(cached)
|
||||
if age <= GracePeriod {
|
||||
slog.Warn("license server said invalid, using cached valid result (grace period)",
|
||||
"server_reason", r.Reason, "cache_age", age, "grace", GracePeriod)
|
||||
return cached
|
||||
}
|
||||
slog.Warn("license server said invalid, cached valid result is too old",
|
||||
"server_reason", r.Reason, "cache_age", age, "grace", GracePeriod)
|
||||
}
|
||||
slog.Warn("license invalid", "reason", r.Reason)
|
||||
return r
|
||||
} else {
|
||||
slog.Warn("license verify failed, falling back to cache", "error", err)
|
||||
}
|
||||
if r, err := c.LoadCache(); err == nil {
|
||||
slog.Warn("using cached license", "cached_at", r.CachedAt)
|
||||
return r
|
||||
}
|
||||
return &Result{Valid: false, Reason: "verify_failed_no_cache", Type: "license"}
|
||||
}
|
||||
|
||||
// No key — trial mode.
|
||||
r, err := c.Trial()
|
||||
if err != nil {
|
||||
slog.Error("trial check failed", "error", err)
|
||||
return &Result{Valid: false, Reason: "trial_check_failed", Type: "trial"}
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// graceAge berechnet das Alter des Cache-Eintrags. Fallback auf weit-
|
||||
// in-der-Zukunft wenn cached_at fehlt — dann gilt die Cache als zu alt
|
||||
// und Grace greift NICHT (defensiv: lieber einmal Auth-Fragen als
|
||||
// dauerhaft falsche Lizenz nutzen).
|
||||
func graceAge(r *Result) time.Duration {
|
||||
if r == nil || r.CachedAt == nil {
|
||||
return 100 * 365 * 24 * time.Hour
|
||||
}
|
||||
return time.Since(*r.CachedAt)
|
||||
}
|
||||
|
||||
// StartPeriodicVerification re-checks the license every VerifyInterval.
|
||||
// Writes each result back to the cache; does not exit on failure.
|
||||
func (c *Client) StartPeriodicVerification(key string) {
|
||||
go func() {
|
||||
ticker := time.NewTicker(VerifyInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
r := c.Check(key)
|
||||
if !r.Valid {
|
||||
slog.Warn("periodic license check: invalid",
|
||||
"reason", r.Reason, "self_service", DefaultSelfServiceURL)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SystemFingerprint is a stable SHA-256 over machine-id, the first active
|
||||
// non-loopback MAC and the hostname. Identical bytes on the same machine
|
||||
// across reboots; changes when network hardware or hostname changes.
|
||||
func (c *Client) SystemFingerprint() string {
|
||||
var parts []string
|
||||
if b, err := c.ReadMachineID(); err == nil {
|
||||
parts = append(parts, strings.TrimSpace(string(b)))
|
||||
}
|
||||
ifaces, _ := c.InterfacesFn()
|
||||
for _, i := range ifaces {
|
||||
if len(i.HardwareAddr) > 0 && i.Flags&net.FlagLoopback == 0 && i.Flags&net.FlagUp != 0 {
|
||||
parts = append(parts, i.HardwareAddr.String())
|
||||
break
|
||||
}
|
||||
}
|
||||
if h, err := c.OSHostname(); err == nil {
|
||||
parts = append(parts, h)
|
||||
}
|
||||
sum := sha256.Sum256([]byte(strings.Join(parts, "|")))
|
||||
return fmt.Sprintf("%x", sum)
|
||||
}
|
||||
|
||||
// Cache + Trial helpers --------------------------------------------------------
|
||||
|
||||
func (c *Client) cachePath() string { return filepath.Join(c.CacheDir, defaultCacheFile) }
|
||||
func (c *Client) trialPath() string { return filepath.Join(c.CacheDir, defaultTrialFile) }
|
||||
|
||||
type cacheEntry struct {
|
||||
Result *Result `json:"result"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
}
|
||||
|
||||
// SaveCache writes a verified result to disk. Errors are logged, not fatal.
|
||||
func (c *Client) SaveCache(r *Result) error {
|
||||
if err := os.MkdirAll(c.CacheDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
entry := cacheEntry{Result: r, CachedAt: c.ClockNow()}
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.cachePath(), data, 0o600)
|
||||
}
|
||||
|
||||
// LoadCache reads the cached result; errors if missing, malformed or older
|
||||
// than CacheMaxAge.
|
||||
func (c *Client) LoadCache() (*Result, error) {
|
||||
data, err := os.ReadFile(c.cachePath())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entry cacheEntry
|
||||
if err := json.Unmarshal(data, &entry); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.ClockNow().Sub(entry.CachedAt) > CacheMaxAge {
|
||||
return nil, errors.New("cache expired")
|
||||
}
|
||||
if entry.Result != nil {
|
||||
entry.Result.CachedAt = &entry.CachedAt
|
||||
}
|
||||
return entry.Result, nil
|
||||
}
|
||||
|
||||
type trialInfo struct {
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
}
|
||||
|
||||
// Trial returns an active trial result while inside TrialDuration, else
|
||||
// a Valid=false "trial_expired" result. Creates the marker file on first call.
|
||||
func (c *Client) Trial() (*Result, error) {
|
||||
var info trialInfo
|
||||
data, err := os.ReadFile(c.trialPath())
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
info = trialInfo{StartedAt: c.ClockNow()}
|
||||
if err := os.MkdirAll(c.CacheDir, 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, _ := json.Marshal(info)
|
||||
if err := os.WriteFile(c.trialPath(), raw, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.Info("nmg trial started",
|
||||
"expires_at", info.StartedAt.Add(TrialDuration))
|
||||
} else {
|
||||
if err := json.Unmarshal(data, &info); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
remaining := TrialDuration - c.ClockNow().Sub(info.StartedAt)
|
||||
if remaining <= 0 {
|
||||
return &Result{Valid: false, Reason: "trial_expired", Type: "trial"}, nil
|
||||
}
|
||||
daysLeft := int(remaining.Hours()/24) + 1
|
||||
expiresAt := info.StartedAt.Add(TrialDuration)
|
||||
|
||||
// Trial schaltet alle Pro-Features frei, Limits bleiben 0 (= Skip-
|
||||
// Enforcement-Konvention). nmg-Backend prüft `lim > 0 && count >= lim`
|
||||
// und blockiert in Trial-Mode niemanden.
|
||||
return &Result{
|
||||
Valid: true,
|
||||
Type: "trial",
|
||||
Status: "active",
|
||||
Product: "NetCell MailGuard",
|
||||
ExpiresAt: &expiresAt,
|
||||
GracePeriod: daysLeft <= 2,
|
||||
Features: map[string]bool{
|
||||
"reporting": true,
|
||||
"eu_portal": true,
|
||||
"digest": true,
|
||||
"whitelabel": true,
|
||||
"rest_api_write": true,
|
||||
},
|
||||
Limits: map[string]int64{
|
||||
"max_domains": 0, // 0 = Skip-Enforcement (Trial-Konvention)
|
||||
"max_nodes": 0,
|
||||
"max_activations": 0,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
52
internal/license/signature.go
Normal file
52
internal/license/signature.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// SignatureKey is an Ed25519 public key used to verify license-server
|
||||
// responses. Shipped as hardcoded base64 constants; multiple keys allow
|
||||
// rotation.
|
||||
type SignatureKey = ed25519.PublicKey
|
||||
|
||||
// Default signing keys, base64 of the raw 32-byte Ed25519 public key.
|
||||
// These must match the keys used by license.netcell-it.com to sign the
|
||||
// verify response's X-Signature header — they are intentionally identical
|
||||
// to the enconf/netcell-webpanel keys (same licensing backend).
|
||||
const (
|
||||
signingKeyPrimaryB64 = "uyXQLl8hFgI4rvvr5pfyF0SmFw1j2R849OL3HUZov5I="
|
||||
signingKeyNextB64 = "zSdKn799Fmu1KaZPYfkB5gDVqeU2doIUFWvmvXigN6M="
|
||||
)
|
||||
|
||||
// DefaultSigningKeys decodes the embedded base64 keys into Ed25519 public
|
||||
// keys. Invalid entries are skipped with a warning.
|
||||
func DefaultSigningKeys() []SignatureKey {
|
||||
var out []SignatureKey
|
||||
for _, b64 := range []string{signingKeyPrimaryB64, signingKeyNextB64} {
|
||||
raw, err := base64.StdEncoding.DecodeString(b64)
|
||||
if err != nil || len(raw) != ed25519.PublicKeySize {
|
||||
slog.Error("license: invalid embedded signing key", "base64", b64)
|
||||
continue
|
||||
}
|
||||
out = append(out, ed25519.PublicKey(raw))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VerifySignature accepts base64-encoded Ed25519 signatures in the
|
||||
// X-Signature header of a license response and checks them against all
|
||||
// provided keys — success on any match.
|
||||
func VerifySignature(keys []SignatureKey, body []byte, signatureB64 string) bool {
|
||||
sig, err := base64.StdEncoding.DecodeString(signatureB64)
|
||||
if err != nil || len(sig) != ed25519.SignatureSize {
|
||||
return false
|
||||
}
|
||||
for _, pk := range keys {
|
||||
if ed25519.Verify(pk, body, sig) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user