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