17d8ec3988
- release: пакет обновления (ExtractBundle/ReadStagedManifest/WriteBundle), Artifact.MinCore, план блокирует модуль, которому нужно ядро новее, чем будет после установки - bj-release: модули получают min_core = версия релиза - настройки → Обновления: «Обновить из файла» (zip до 2 ГБ), фон как у сетевого обновления, причина блокировки в таблице компонентов Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q4YVo4UMopqT5DzicqpPjL
202 lines
8.6 KiB
Go
202 lines
8.6 KiB
Go
// Package release — формат манифеста релиза артефактории и его подпись
|
|
// Ed25519. Используется тремя сторонами:
|
|
//
|
|
// - cmd/bj-release — собирает манифест из артефактов и подписывает;
|
|
// - cmd/bj-artifactory — отдаёт манифест и файлы по HTTP;
|
|
// - bj-server (auto-update) — скачивает манифест, проверяет подпись,
|
|
// сравнивает версии и обновляет компоненты.
|
|
//
|
|
// Доверие строится на одном корневом Ed25519-ключе: приватный держит
|
|
// издатель (мы), публичный зашит в клиента (bj-server) и в установщик.
|
|
// Манифест подписывается целиком в каноничном виде — клиент проверяет
|
|
// подпись ДО того, как доверять версиям и хешам артефактов.
|
|
package release
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"time"
|
|
)
|
|
|
|
// CurrentSchema — версия формата манифеста (на случай эволюции).
|
|
const CurrentSchema = 1
|
|
|
|
// Artifact — один распространяемый файл (бинарь/скрипт/архив).
|
|
type Artifact struct {
|
|
Name string `json:"name"` // логическое имя: "bj-server", "install-validata.sh"
|
|
File string `json:"file"` // имя файла в хранилище
|
|
Version string `json:"version,omitempty"` // версия компонента (если своя)
|
|
SHA256 string `json:"sha256"` // hex-хеш содержимого
|
|
Size int64 `json:"size"` // размер в байтах
|
|
Exec bool `json:"exec,omitempty"` // ставить ли +x при установке
|
|
// MinCore — минимальная версия ядра, с которой работает этот модуль. Модуль из
|
|
// релиза X собран под ядро X: ставить его на более старое ядро нельзя — сначала ядро.
|
|
MinCore string `json:"min_core,omitempty"`
|
|
}
|
|
|
|
// Manifest — описание релиза. Подписывается целиком (каноничный JSON).
|
|
type Manifest struct {
|
|
Schema int `json:"schema"`
|
|
Version string `json:"version"` // версия релиза, напр. "1.2.0"
|
|
Channel string `json:"channel"` // "stable" | "beta"
|
|
ReleasedAt time.Time `json:"released_at"` // время сборки релиза
|
|
Notes string `json:"notes,omitempty"` // что нового (для UI)
|
|
MinVersion string `json:"min_version,omitempty"` // мин. версия для прямого апгрейда
|
|
Artifacts []Artifact `json:"artifacts"`
|
|
}
|
|
|
|
// SignedManifest — манифест + отделённая подпись. Именно это лежит в
|
|
// /v1/manifest.json и скачивается клиентом.
|
|
//
|
|
// Payload хранится как base64 от каноничного JSON Manifest — НЕ как
|
|
// вложенный JSON. Иначе json.MarshalIndent переформатировал бы содержимое
|
|
// и подписанные байты разошлись бы с прочитанными.
|
|
type SignedManifest struct {
|
|
Payload string `json:"payload"` // base64(каноничный JSON Manifest)
|
|
Signature string `json:"signature"` // base64(ed25519 over каноничным JSON)
|
|
KeyID string `json:"key_id"` // идентификатор публичного ключа
|
|
}
|
|
|
|
// Canonical сериализует манифест детерминированно (для подписи/проверки).
|
|
// json.Marshal в Go стабилен по порядку полей структуры — этого достаточно,
|
|
// т.к. подписываем и проверяем одни и те же байты Payload.
|
|
func (m *Manifest) Canonical() ([]byte, error) {
|
|
if m.Schema == 0 {
|
|
m.Schema = CurrentSchema
|
|
}
|
|
return json.Marshal(m)
|
|
}
|
|
|
|
// Sign подписывает манифест приватным ключом и возвращает SignedManifest.
|
|
func Sign(m *Manifest, priv ed25519.PrivateKey, keyID string) (*SignedManifest, error) {
|
|
payload, err := m.Canonical()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("release: canonical: %w", err)
|
|
}
|
|
sig := ed25519.Sign(priv, payload)
|
|
return &SignedManifest{
|
|
Payload: base64.StdEncoding.EncodeToString(payload),
|
|
Signature: base64.StdEncoding.EncodeToString(sig),
|
|
KeyID: keyID,
|
|
}, nil
|
|
}
|
|
|
|
// Verify проверяет подпись и возвращает разобранный Manifest. Это
|
|
// единственный способ получить доверенный Manifest на стороне клиента.
|
|
func Verify(sm *SignedManifest, pub ed25519.PublicKey) (*Manifest, error) {
|
|
sig, err := base64.StdEncoding.DecodeString(sm.Signature)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("release: decode signature: %w", err)
|
|
}
|
|
payload, err := base64.StdEncoding.DecodeString(sm.Payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("release: decode payload: %w", err)
|
|
}
|
|
if !ed25519.Verify(pub, payload, sig) {
|
|
return nil, errors.New("release: подпись манифеста недействительна")
|
|
}
|
|
var m Manifest
|
|
if err := json.Unmarshal(payload, &m); err != nil {
|
|
return nil, fmt.Errorf("release: unmarshal payload: %w", err)
|
|
}
|
|
if m.Schema != CurrentSchema {
|
|
return nil, fmt.Errorf("release: неподдерживаемая схема манифеста %d (ожидается %d)", m.Schema, CurrentSchema)
|
|
}
|
|
return &m, nil
|
|
}
|
|
|
|
// HashFile считает sha256 (hex) и размер файла — для заполнения Artifact.
|
|
func HashFile(path string) (sha string, size int64, err error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
defer f.Close()
|
|
h := sha256.New()
|
|
n, err := io.Copy(h, f)
|
|
if err != nil {
|
|
return "", 0, err
|
|
}
|
|
return hex.EncodeToString(h.Sum(nil)), n, nil
|
|
}
|
|
|
|
// VerifyArtifact проверяет, что файл по пути совпадает с Artifact (хеш+размер).
|
|
func VerifyArtifact(path string, a Artifact) error {
|
|
sha, size, err := HashFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if size != a.Size {
|
|
return fmt.Errorf("release: размер %s не совпал: %d != %d", a.Name, size, a.Size)
|
|
}
|
|
if sha != a.SHA256 {
|
|
return fmt.Errorf("release: sha256 %s не совпал", a.Name)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Ключи ---
|
|
|
|
// LoadPrivateKey читает Ed25519 приватный ключ из файла (base64 seed, 32 байта).
|
|
func LoadPrivateKey(path string) (ed25519.PrivateKey, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
seed, err := base64.StdEncoding.DecodeString(stringsTrim(string(b)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("release: decode seed: %w", err)
|
|
}
|
|
if len(seed) != ed25519.SeedSize {
|
|
return nil, fmt.Errorf("release: неверный размер seed %d (ожидается %d)", len(seed), ed25519.SeedSize)
|
|
}
|
|
return ed25519.NewKeyFromSeed(seed), nil
|
|
}
|
|
|
|
// LoadPublicKey читает Ed25519 публичный ключ из файла (base64, 32 байта).
|
|
func LoadPublicKey(path string) (ed25519.PublicKey, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pub, err := base64.StdEncoding.DecodeString(stringsTrim(string(b)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("release: decode pubkey: %w", err)
|
|
}
|
|
if len(pub) != ed25519.PublicKeySize {
|
|
return nil, fmt.Errorf("release: неверный размер pubkey %d", len(pub))
|
|
}
|
|
return ed25519.PublicKey(pub), nil
|
|
}
|
|
|
|
// ParsePublicKey разбирает публичный ключ из base64-строки (для зашитого в клиента).
|
|
func ParsePublicKey(b64 string) (ed25519.PublicKey, error) {
|
|
pub, err := base64.StdEncoding.DecodeString(stringsTrim(b64))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(pub) != ed25519.PublicKeySize {
|
|
return nil, fmt.Errorf("release: неверный размер pubkey %d", len(pub))
|
|
}
|
|
return ed25519.PublicKey(pub), nil
|
|
}
|
|
|
|
func stringsTrim(s string) string {
|
|
// убираем пробелы/переводы строк вокруг base64
|
|
start, end := 0, len(s)
|
|
for start < end && (s[start] == ' ' || s[start] == '\n' || s[start] == '\r' || s[start] == '\t') {
|
|
start++
|
|
}
|
|
for end > start && (s[end-1] == ' ' || s[end-1] == '\n' || s[end-1] == '\r' || s[end-1] == '\t') {
|
|
end--
|
|
}
|
|
return s[start:end]
|
|
}
|