// 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 при установке } // 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] }