133 lines
3.9 KiB
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)
|
|
}
|