feat: живой цикл M2M с НРД + мастер установки ключа на флешку
Инфраструктура M2M (живой обмен с НРД через ИШ): - обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение ответа; INFO → ждём Decision; идемпотентность поллера - fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO - сырой XML ответа НРД в карточке заявки (для пересылки в ТП) - тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes, 4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта - редирект из теста сразу в карточку заявки Мастер установки ключа Валидаты на флешку (admin/setup/keywizard): - пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник сертификатов (CRL) → перезапуск+проверка ИШ → готово - привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен, bj-server остаётся в песочнице - сохранение структуры профиля архива (spr<N>), перечисление съёмных USB Прочее: - пакет-доказательство для ТП НРД + форма регистрации участника M2M - эталонные образцы робота (DOC/m2m_robot_samples) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user