Netpulse_SasS/server/internal/crypto/secrets.go
zotac 8a92ef8a45 Етап 2: серверна сторона AgentService
Автентифікація зондів за токеном, побудова 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>
2026-08-14 04:13:40 +03:00

173 lines
4.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}