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,110 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client — клиент артефактории: скачивает подписанный манифест и артефакты,
|
||||
// проверяет подпись зашитым публичным ключом и sha256 каждого файла.
|
||||
type Client struct {
|
||||
BaseURL string // напр. https://updates.example.com
|
||||
Channel string // "stable" | "beta"
|
||||
Pub ed25519.PublicKey // публичный ключ издателя (зашит в bj-server)
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewClient собирает клиент. pubB64 — публичный ключ в base64 (зашитый).
|
||||
func NewClient(baseURL, channel, pubB64 string) (*Client, error) {
|
||||
pub, err := ParsePublicKey(pubB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release: публичный ключ: %w", err)
|
||||
}
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
Channel: channel,
|
||||
Pub: pub,
|
||||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FetchManifest скачивает /v1/<channel>/manifest.json и проверяет подпись.
|
||||
// Возвращает доверенный Manifest (или ошибку, если подпись не сошлась).
|
||||
func (c *Client) FetchManifest(ctx context.Context) (*Manifest, error) {
|
||||
url := fmt.Sprintf("%s/v1/%s/manifest.json", c.BaseURL, c.Channel)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release: запрос манифеста: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("release: манифест HTTP %d", resp.StatusCode)
|
||||
}
|
||||
var sm SignedManifest
|
||||
if err := json.NewDecoder(resp.Body).Decode(&sm); err != nil {
|
||||
return nil, fmt.Errorf("release: разбор манифеста: %w", err)
|
||||
}
|
||||
return Verify(&sm, c.Pub) // здесь проверяется подпись
|
||||
}
|
||||
|
||||
// DownloadArtifact скачивает артефакт в destDir, проверяет sha256/размер по
|
||||
// манифесту, выставляет +x при необходимости. Возвращает путь к файлу.
|
||||
// Скачивает во временный файл и переименовывает атомарно.
|
||||
func (c *Client) DownloadArtifact(ctx context.Context, a Artifact, destDir string) (string, error) {
|
||||
url := fmt.Sprintf("%s/v1/%s/files/%s", c.BaseURL, c.Channel, a.File)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("release: скачивание %s: %w", a.Name, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("release: %s HTTP %d", a.Name, resp.StatusCode)
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(destDir, "."+a.File+".dl-*")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // если что-то пойдёт не так — подчистим
|
||||
|
||||
if _, err := io.Copy(tmp, resp.Body); err != nil {
|
||||
tmp.Close()
|
||||
return "", err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Проверка целостности по манифесту (подпись манифеста уже проверена).
|
||||
if err := VerifyArtifact(tmpName, a); err != nil {
|
||||
return "", err
|
||||
}
|
||||
mode := os.FileMode(0o644)
|
||||
if a.Exec {
|
||||
mode = 0o755
|
||||
}
|
||||
if err := os.Chmod(tmpName, mode); err != nil {
|
||||
return "", err
|
||||
}
|
||||
final := filepath.Join(destDir, a.File)
|
||||
if err := os.Rename(tmpName, final); err != nil { // атомарно в пределах destDir
|
||||
return "", err
|
||||
}
|
||||
return final, nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
// 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]
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSignVerifyRoundTrip(t *testing.T) {
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &Manifest{
|
||||
Version: "1.2.3",
|
||||
Channel: "stable",
|
||||
ReleasedAt: time.Now().UTC().Truncate(time.Second),
|
||||
Artifacts: []Artifact{
|
||||
{Name: "bj-server", File: "bj-server", SHA256: "abc", Size: 100, Exec: true},
|
||||
},
|
||||
}
|
||||
sm, err := Sign(m, priv, "main")
|
||||
if err != nil {
|
||||
t.Fatalf("Sign: %v", err)
|
||||
}
|
||||
got, err := Verify(sm, pub)
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if got.Version != m.Version || got.Channel != m.Channel || len(got.Artifacts) != 1 {
|
||||
t.Fatalf("manifest mismatch: %+v", got)
|
||||
}
|
||||
if got.Schema != CurrentSchema {
|
||||
t.Fatalf("schema = %d, want %d", got.Schema, CurrentSchema)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsTamper(t *testing.T) {
|
||||
pub, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
m := &Manifest{Version: "1.0.0", Channel: "stable", Artifacts: []Artifact{{Name: "x"}}}
|
||||
sm, _ := Sign(m, priv, "main")
|
||||
// Подменяем payload на другой манифест — подпись не должна сойтись.
|
||||
other := &Manifest{Version: "9.9.9", Channel: "stable", Artifacts: []Artifact{{Name: "evil"}}}
|
||||
bad, _ := other.Canonical()
|
||||
sm.Payload = base64.StdEncoding.EncodeToString(bad)
|
||||
if _, err := Verify(sm, pub); err == nil {
|
||||
t.Fatal("Verify принял подделанный payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyRejectsWrongKey(t *testing.T) {
|
||||
_, priv, _ := ed25519.GenerateKey(rand.Reader)
|
||||
other, _, _ := ed25519.GenerateKey(rand.Reader) // чужой публичный ключ
|
||||
m := &Manifest{Version: "1.0.0", Channel: "stable", Artifacts: []Artifact{{Name: "x"}}}
|
||||
sm, _ := Sign(m, priv, "main")
|
||||
if _, err := Verify(sm, other); err == nil {
|
||||
t.Fatal("Verify принял подпись чужим ключом")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package release
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// IsNewer возвращает true, если версия remote строго новее local.
|
||||
// Понимает semver вида "MAJOR.MINOR.PATCH" (с опциональным префиксом "v"
|
||||
// и суффиксом "-beta" — суффикс игнорируется при сравнении чисел).
|
||||
// Некорректные/пустые версии считаются «старыми» (0.0.0).
|
||||
func IsNewer(remote, local string) bool {
|
||||
rm := parseVer(remote)
|
||||
lc := parseVer(local)
|
||||
for i := 0; i < 3; i++ {
|
||||
if rm[i] != lc[i] {
|
||||
return rm[i] > lc[i]
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseVer(s string) [3]int {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.TrimPrefix(s, "v")
|
||||
if i := strings.IndexAny(s, "-+"); i >= 0 {
|
||||
s = s[:i] // отбрасываем -beta, +build
|
||||
}
|
||||
var out [3]int
|
||||
parts := strings.Split(s, ".")
|
||||
for i := 0; i < 3 && i < len(parts); i++ {
|
||||
n, _ := strconv.Atoi(parts[i])
|
||||
out[i] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user