Автентифікація зондів за токеном, побудова TaskPlan із детермінованим schedule_offset, видача розшифрованих креденшелів із TTL, запис телеметрії в гіпертаблиці, резолвер сусідів LLDP/CDP у topo.links, прийом конфігів. Ізоляція тенантів робиться двічі — RLS плюс явний предикат tenant_id, бо RLS не працює на гіпертаблях, а саме туди йде вся телеметрія. Агент: -token і передача його в метаданих; MarkAllPending() перереєстровує серії на початку сесії замість обнуляти нумерацію й губити буфер. Перевірено на Debian 13 / PG 17.11 / TimescaleDB 2.29.1: 11 інтеграційних тестів проти живої БД (-race), плюс живий прогін справжнього агента проти справжнього сервера — телеметрія, статус пристрою, heartbeat у базі. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
173 lines
4.9 KiB
Go
173 lines
4.9 KiB
Go
// Package crypto — шифрування секретів для core.secrets.
|
||
//
|
||
// AES-GCM-256. Ключі шифрування даних (DEK) живуть поза БД: у
|
||
// KMS/Vault або, для self-hosted, у файлі з правами 0600. У таблиці
|
||
// лежить лише key_id, тому дамп бази без доступу до ключів не дає
|
||
// жодного пароля від обладнання клієнта.
|
||
package crypto
|
||
|
||
import (
|
||
"crypto/aes"
|
||
"crypto/cipher"
|
||
"crypto/rand"
|
||
"errors"
|
||
"fmt"
|
||
"sync"
|
||
)
|
||
|
||
const (
|
||
KeySize = 32 // AES-256
|
||
NonceSize = 12 // 96 біт, рекомендація NIST для GCM
|
||
TagSize = 16
|
||
)
|
||
|
||
var (
|
||
ErrUnknownKey = errors.New("невідомий key_id")
|
||
ErrBadKeySize = errors.New("ключ має бути 32 байти")
|
||
)
|
||
|
||
// Keyring — набір DEK за ідентифікаторами. Кілька ключів одночасно
|
||
// потрібні для ротації: нові секрети шифруються активним ключем,
|
||
// старі ще читаються попереднім.
|
||
type Keyring struct {
|
||
mu sync.RWMutex
|
||
keys map[string][]byte
|
||
active string
|
||
}
|
||
|
||
func NewKeyring() *Keyring {
|
||
return &Keyring{keys: make(map[string][]byte)}
|
||
}
|
||
|
||
// Add додає ключ. Перший доданий стає активним.
|
||
func (k *Keyring) Add(keyID string, key []byte) error {
|
||
if len(key) != KeySize {
|
||
return fmt.Errorf("%w: %d", ErrBadKeySize, len(key))
|
||
}
|
||
k.mu.Lock()
|
||
defer k.mu.Unlock()
|
||
|
||
cp := make([]byte, KeySize)
|
||
copy(cp, key)
|
||
k.keys[keyID] = cp
|
||
if k.active == "" {
|
||
k.active = keyID
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// SetActive призначає ключ, яким шифруються нові секрети.
|
||
func (k *Keyring) SetActive(keyID string) error {
|
||
k.mu.Lock()
|
||
defer k.mu.Unlock()
|
||
if _, ok := k.keys[keyID]; !ok {
|
||
return ErrUnknownKey
|
||
}
|
||
k.active = keyID
|
||
return nil
|
||
}
|
||
|
||
func (k *Keyring) ActiveKeyID() string {
|
||
k.mu.RLock()
|
||
defer k.mu.RUnlock()
|
||
return k.active
|
||
}
|
||
|
||
func (k *Keyring) aead(keyID string) (cipher.AEAD, error) {
|
||
k.mu.RLock()
|
||
key, ok := k.keys[keyID]
|
||
k.mu.RUnlock()
|
||
if !ok {
|
||
return nil, fmt.Errorf("%w: %q", ErrUnknownKey, keyID)
|
||
}
|
||
|
||
block, err := aes.NewCipher(key)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return cipher.NewGCM(block)
|
||
}
|
||
|
||
// Secret — те, що лягає в рядок core.secrets.
|
||
type Secret struct {
|
||
KeyID string
|
||
Nonce []byte
|
||
Ciphertext []byte
|
||
AuthTag []byte
|
||
}
|
||
|
||
// Encrypt шифрує активним ключем.
|
||
//
|
||
// aad (additional authenticated data) прив'язує шифротекст до
|
||
// контексту — зазвичай tenant_id||kind||object_id. Без цього рядок
|
||
// із секретом можна було б переставити на інший пристрій або тенант
|
||
// прямим UPDATE у БД, і розшифрування б відпрацювало.
|
||
func (k *Keyring) Encrypt(plaintext []byte, aad string) (*Secret, error) {
|
||
keyID := k.ActiveKeyID()
|
||
if keyID == "" {
|
||
return nil, errors.New("keyring порожній")
|
||
}
|
||
|
||
aead, err := k.aead(keyID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
nonce := make([]byte, NonceSize)
|
||
if _, err := rand.Read(nonce); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
sealed := aead.Seal(nil, nonce, plaintext, []byte(aad))
|
||
if len(sealed) < TagSize {
|
||
return nil, errors.New("несподівано короткий шифротекст")
|
||
}
|
||
|
||
// Схема тримає тег окремо від шифротексту, тому розділяємо:
|
||
// Go дописує його в кінець.
|
||
return &Secret{
|
||
KeyID: keyID,
|
||
Nonce: nonce,
|
||
Ciphertext: sealed[:len(sealed)-TagSize],
|
||
AuthTag: sealed[len(sealed)-TagSize:],
|
||
}, nil
|
||
}
|
||
|
||
// Decrypt розшифровує й перевіряє цілісність.
|
||
func (k *Keyring) Decrypt(s *Secret, aad string) ([]byte, error) {
|
||
if s == nil {
|
||
return nil, errors.New("порожній секрет")
|
||
}
|
||
if len(s.Nonce) != NonceSize {
|
||
return nil, fmt.Errorf("nonce має бути %d байт, отримано %d", NonceSize, len(s.Nonce))
|
||
}
|
||
if len(s.AuthTag) != TagSize {
|
||
return nil, fmt.Errorf("auth_tag має бути %d байт, отримано %d", TagSize, len(s.AuthTag))
|
||
}
|
||
|
||
aead, err := k.aead(s.KeyID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
sealed := make([]byte, 0, len(s.Ciphertext)+TagSize)
|
||
sealed = append(sealed, s.Ciphertext...)
|
||
sealed = append(sealed, s.AuthTag...)
|
||
|
||
out, err := aead.Open(nil, s.Nonce, sealed, []byte(aad))
|
||
if err != nil {
|
||
// Не розкриваємо, що саме не зійшлось: помилка автентифікації
|
||
// GCM не має ставати оракулом.
|
||
return nil, errors.New("не вдалося розшифрувати секрет")
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// GenerateKey — новий випадковий DEK.
|
||
func GenerateKey() ([]byte, error) {
|
||
key := make([]byte, KeySize)
|
||
if _, err := rand.Read(key); err != nil {
|
||
return nil, err
|
||
}
|
||
return key, nil
|
||
}
|