// Package license — формат лицензии Bridge-and-Join-s и её подпись Ed25519. // // Лицензия — самодостаточный подписанный токен (offline-проверяемый): // клиент проверяет подпись зашитым публичным ключом и срок действия БЕЗ // обращения к серверу. Это значит, что on-prem bj-server продолжает // работать даже если license-сервер недоступен. // // Online-сервер (cmd/bj-license-server) нужен только для отзыва (revocation) // и выдачи новых ключей. Базовая модель — годовой ключ: выпустили на год, // клиент проверяет offline; перед обновлением bj-server гейтит установку // валидной непросроченной лицензией. // // Издатель держит приватный ключ в секрете; публичный зашит в bj-server. package license import ( "crypto/ed25519" "encoding/base64" "encoding/json" "errors" "fmt" "os" "strings" "time" ) const CurrentSchema = 1 // Plan — тариф лицензии. type Plan string const ( PlanFree Plan = "free" PlanPro Plan = "pro" PlanEnterprise Plan = "enterprise" ) // License — содержимое лицензии (подписывается целиком). type License struct { Schema int `json:"schema"` ID string `json:"id"` // UUID лицензии Tenant string `json:"tenant"` // организация-клиент Product string `json:"product"` // "bj-server" Plan Plan `json:"plan"` // free|pro|enterprise IssuedAt time.Time `json:"issued_at"` // дата выпуска ExpiresAt time.Time `json:"expires_at"` // дата окончания (годовой ключ) Features []string `json:"features,omitempty"` // "updates","web-cabinet",... MaxNodes int `json:"max_nodes,omitempty"` // лимит инсталляций (0 = без лимита) Note string `json:"note,omitempty"` } // Token — лицензия + подпись. Именно это вводит клиент (одна base64-строка // или JSON-файл). Формат: base64url(payload).base64url(sig) — компактно. type Token struct { Payload string `json:"payload"` // base64(каноничный JSON License) Signature string `json:"signature"` // base64(ed25519 over каноничным JSON) KeyID string `json:"key_id,omitempty"` } // Canonical сериализует лицензию детерминированно (для подписи/проверки). func (l *License) Canonical() ([]byte, error) { if l.Schema == 0 { l.Schema = CurrentSchema } return json.Marshal(l) } // Valid проверяет срок действия на момент now. func (l *License) Valid(now time.Time) error { if now.Before(l.IssuedAt.Add(-24 * time.Hour)) { return errors.New("license: ещё не действует (issued_at в будущем)") } if now.After(l.ExpiresAt) { return fmt.Errorf("license: истекла %s", l.ExpiresAt.Format("02.01.2006")) } return nil } // HasFeature — включена ли фича (или план enterprise — всё включено). func (l *License) HasFeature(f string) bool { if l.Plan == PlanEnterprise { return true } for _, x := range l.Features { if x == f { return true } } return false } // AllowsUpdates — разрешены ли обновления по этой лицензии. func (l *License) AllowsUpdates() bool { return l.HasFeature("updates") } // DaysLeft — сколько дней до окончания (может быть отрицательным). func (l *License) DaysLeft(now time.Time) int { return int(l.ExpiresAt.Sub(now).Hours() / 24) } // Sign подписывает лицензию и возвращает Token. func Sign(l *License, priv ed25519.PrivateKey, keyID string) (*Token, error) { payload, err := l.Canonical() if err != nil { return nil, fmt.Errorf("license: canonical: %w", err) } sig := ed25519.Sign(priv, payload) return &Token{ Payload: base64.StdEncoding.EncodeToString(payload), Signature: base64.StdEncoding.EncodeToString(sig), KeyID: keyID, }, nil } // Verify проверяет подпись и возвращает License (срок проверяется отдельно // через License.Valid — Verify только про подлинность). func Verify(t *Token, pub ed25519.PublicKey) (*License, error) { sig, err := base64.StdEncoding.DecodeString(t.Signature) if err != nil { return nil, fmt.Errorf("license: decode signature: %w", err) } payload, err := base64.StdEncoding.DecodeString(t.Payload) if err != nil { return nil, fmt.Errorf("license: decode payload: %w", err) } if !ed25519.Verify(pub, payload, sig) { return nil, errors.New("license: подпись недействительна") } var l License if err := json.Unmarshal(payload, &l); err != nil { return nil, fmt.Errorf("license: unmarshal: %w", err) } if l.Schema != CurrentSchema { return nil, fmt.Errorf("license: неподдерживаемая схема %d", l.Schema) } return &l, nil } // Encode сериализует Token в компактную строку payload.signature[.keyid] // (то, что клиент вставляет в поле «лицензионный ключ»). func (t *Token) Encode() string { s := t.Payload + "." + t.Signature if t.KeyID != "" { s += "." + t.KeyID } return s } // DecodeToken разбирает компактную строку обратно в Token. func DecodeToken(s string) (*Token, error) { parts := strings.Split(strings.TrimSpace(s), ".") if len(parts) < 2 { return nil, errors.New("license: неверный формат ключа (ожидается payload.signature)") } t := &Token{Payload: parts[0], Signature: parts[1]} if len(parts) >= 3 { t.KeyID = parts[2] } return t, nil } // --- Ключи (как в release) --- func LoadPrivateKey(path string) (ed25519.PrivateKey, error) { b, err := os.ReadFile(path) if err != nil { return nil, err } seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(b))) if err != nil { return nil, fmt.Errorf("license: decode seed: %w", err) } if len(seed) != ed25519.SeedSize { return nil, fmt.Errorf("license: неверный размер seed %d", len(seed)) } return ed25519.NewKeyFromSeed(seed), nil } func ParsePublicKey(b64 string) (ed25519.PublicKey, error) { pub, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) if err != nil { return nil, err } if len(pub) != ed25519.PublicKeySize { return nil, fmt.Errorf("license: неверный размер pubkey %d", len(pub)) } return ed25519.PublicKey(pub), nil }