Files
yovision/Sense/server/app/sense/credential/vault.go

133 lines
3.9 KiB
Go

package credential
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
)
const (
EnvironmentKey = "SENSE_CREDENTIAL_KEY"
PurposeONVIF = "onvif"
PurposeRTSP = "rtsp"
keyVersion = "v1"
)
var ErrKeyUnavailable = errors.New("摄像头凭据加密密钥不可用")
// DeviceCredential is deliberately stored separately from the device ledger.
// No HTTP response type embeds this model.
type DeviceCredential struct {
DeviceID string `gorm:"size:36;primaryKey" json:"-"`
Purpose string `gorm:"size:16;primaryKey" json:"-"`
Ciphertext []byte `gorm:"type:bytea;not null" json:"-"`
KeyVersion string `gorm:"size:16;not null" json:"-"`
CreatedAt time.Time `json:"-"`
UpdatedAt time.Time `json:"-"`
}
func (DeviceCredential) TableName() string { return "sense_device_credentials" }
type Vault struct{ key []byte }
func NewVaultFromEnvironment() (*Vault, error) {
encoded := strings.TrimSpace(os.Getenv(EnvironmentKey))
if encoded == "" {
return nil, ErrKeyUnavailable
}
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || len(decoded) != 32 {
return nil, ErrKeyUnavailable
}
return NewVault(decoded)
}
func NewVault(key []byte) (*Vault, error) {
if len(key) != 32 {
return nil, ErrKeyUnavailable
}
copyOfKey := append([]byte(nil), key...)
return &Vault{key: copyOfKey}, nil
}
func (v *Vault) Encrypt(deviceID, purpose, username, password string) ([]byte, error) {
if err := validateScope(deviceID, purpose); err != nil {
return nil, err
}
plaintext, err := json.Marshal(struct {
Username string `json:"username"`
Password string `json:"password"`
}{Username: username, Password: password})
if err != nil {
return nil, fmt.Errorf("encode credential: %w", err)
}
block, err := aes.NewCipher(v.key)
if err != nil {
return nil, fmt.Errorf("initialize credential cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, fmt.Errorf("initialize credential gcm: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
return nil, fmt.Errorf("generate credential nonce: %w", err)
}
return gcm.Seal(nonce, nonce, plaintext, associatedData(deviceID, purpose)), nil
}
// Decrypt is an internal adapter boundary. It is intentionally not exposed by
// any Sense HTTP handler and must only be used for the matching device/purpose.
func (v *Vault) Decrypt(deviceID, purpose string, ciphertext []byte) (string, string, error) {
if err := validateScope(deviceID, purpose); err != nil {
return "", "", err
}
block, err := aes.NewCipher(v.key)
if err != nil {
return "", "", fmt.Errorf("initialize credential cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", fmt.Errorf("initialize credential gcm: %w", err)
}
if len(ciphertext) < gcm.NonceSize() {
return "", "", errors.New("invalid credential ciphertext")
}
plaintext, err := gcm.Open(nil, ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():], associatedData(deviceID, purpose))
if err != nil {
return "", "", errors.New("credential ciphertext does not match device purpose")
}
var value struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err = json.Unmarshal(plaintext, &value); err != nil {
return "", "", errors.New("invalid credential payload")
}
return value.Username, value.Password, nil
}
func Version() string { return keyVersion }
func validateScope(deviceID, purpose string) error {
if strings.TrimSpace(deviceID) == "" {
return errors.New("device id is required")
}
if purpose != PurposeONVIF && purpose != PurposeRTSP {
return errors.New("unsupported credential purpose")
}
return nil
}
func associatedData(deviceID, purpose string) []byte {
return []byte("sense-device-credential-v1\x00" + deviceID + "\x00" + purpose)
}