fix: complete audit security and frontend gaps
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"senlinai-agent/backend/internal/domain"
|
||||
)
|
||||
|
||||
type Gateway struct {
|
||||
db *gorm.DB
|
||||
systemKey string
|
||||
db *gorm.DB
|
||||
systemKey string
|
||||
encryptionSecret string
|
||||
}
|
||||
|
||||
type SelectedKey struct {
|
||||
@@ -19,13 +28,33 @@ type SelectedKey struct {
|
||||
}
|
||||
|
||||
func NewGateway(database *gorm.DB, systemKey string) *Gateway {
|
||||
return &Gateway{db: database, systemKey: systemKey}
|
||||
return NewGatewayWithSecret(database, systemKey, "development-ai-key-secret-change-me")
|
||||
}
|
||||
|
||||
func NewGatewayWithSecret(database *gorm.DB, systemKey string, encryptionSecret string) *Gateway {
|
||||
return &Gateway{db: database, systemKey: systemKey, encryptionSecret: encryptionSecret}
|
||||
}
|
||||
|
||||
func (g *Gateway) SaveUserKey(userID uint, provider string, apiKey string) error {
|
||||
encrypted, err := encryptAPIKey(apiKey, g.encryptionSecret)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := domain.AIKey{UserID: userID, Provider: provider, EncryptedAPIKey: encrypted}
|
||||
return g.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"provider", "encrypted_api_key", "updated_at"}),
|
||||
}).Create(&key).Error
|
||||
}
|
||||
|
||||
func (g *Gateway) SelectKey(userID uint) (SelectedKey, error) {
|
||||
var userKey domain.AIKey
|
||||
if err := g.db.Where("user_id = ?", userID).First(&userKey).Error; err == nil {
|
||||
return SelectedKey{Provider: userKey.Provider, APIKey: userKey.EncryptedAPIKey, KeyType: "user"}, nil
|
||||
apiKey, err := decryptAPIKey(userKey.EncryptedAPIKey, g.encryptionSecret)
|
||||
if err != nil {
|
||||
return SelectedKey{}, err
|
||||
}
|
||||
return SelectedKey{Provider: userKey.Provider, APIKey: apiKey, KeyType: "user"}, nil
|
||||
}
|
||||
if g.systemKey == "" {
|
||||
return SelectedKey{}, errors.New("no ai key available")
|
||||
@@ -43,3 +72,72 @@ func (g *Gateway) RecordCall(userID uint, provider string, usedKeyType string, a
|
||||
Error: errText,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (g *Gateway) CheckRateLimit(userID uint, action string, limit int, window time.Duration) error {
|
||||
if limit <= 0 {
|
||||
return nil
|
||||
}
|
||||
var count int64
|
||||
if err := g.db.Model(&domain.AICallLog{}).
|
||||
Where("user_id = ? AND action = ? AND created_at >= ?", userID, action, time.Now().Add(-window)).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= int64(limit) {
|
||||
return errors.New("ai rate limit exceeded")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encryptAPIKey(apiKey string, secret string) (string, error) {
|
||||
if apiKey == "" {
|
||||
return "", errors.New("api key is required")
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey(secret))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := gcm.Seal(nil, nonce, []byte(apiKey), nil)
|
||||
return "v1:" + base64.RawURLEncoding.EncodeToString(append(nonce, ciphertext...)), nil
|
||||
}
|
||||
|
||||
func decryptAPIKey(encrypted string, secret string) (string, error) {
|
||||
if len(encrypted) < 3 || encrypted[:3] != "v1:" {
|
||||
return encrypted, nil
|
||||
}
|
||||
raw, err := base64.RawURLEncoding.DecodeString(encrypted[3:])
|
||||
if err != nil {
|
||||
return "", errors.New("invalid encrypted api key")
|
||||
}
|
||||
block, err := aes.NewCipher(encryptionKey(secret))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(raw) < gcm.NonceSize() {
|
||||
return "", errors.New("invalid encrypted api key")
|
||||
}
|
||||
nonce := raw[:gcm.NonceSize()]
|
||||
ciphertext := raw[gcm.NonceSize():]
|
||||
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
|
||||
if err != nil {
|
||||
return "", errors.New("invalid encrypted api key")
|
||||
}
|
||||
return string(plaintext), nil
|
||||
}
|
||||
|
||||
func encryptionKey(secret string) []byte {
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
return sum[:]
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package ai
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -22,6 +23,21 @@ func TestSelectKeyPrefersUserKey(t *testing.T) {
|
||||
require.Equal(t, "user-key", selected.APIKey)
|
||||
}
|
||||
|
||||
func TestSaveUserKeyEncryptsStoredKey(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGatewayWithSecret(database, "system-key", "test-encryption-secret")
|
||||
|
||||
require.NoError(t, gateway.SaveUserKey(3, "openai", "user-key"))
|
||||
|
||||
var stored domain.AIKey
|
||||
require.NoError(t, database.Where("user_id = ?", 3).First(&stored).Error)
|
||||
require.NotEqual(t, "user-key", stored.EncryptedAPIKey)
|
||||
require.Contains(t, stored.EncryptedAPIKey, "v1:")
|
||||
selected, err := gateway.SelectKey(3)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user-key", selected.APIKey)
|
||||
}
|
||||
|
||||
func TestSelectKeyFallsBackToSystemKey(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGateway(database, "system-key")
|
||||
@@ -58,6 +74,16 @@ func TestRecordCallStoresAuditFields(t *testing.T) {
|
||||
require.Equal(t, "rate limited", log.Error)
|
||||
}
|
||||
|
||||
func TestCheckRateLimitRejectsCallsOverWindow(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
gateway := NewGateway(database, "system-key")
|
||||
require.NoError(t, gateway.RecordCall(3, "openai", "system", "inbox_analyze", "succeeded", ""))
|
||||
|
||||
err := gateway.CheckRateLimit(3, "inbox_analyze", 1, time.Hour)
|
||||
|
||||
require.ErrorContains(t, err, "ai rate limit exceeded")
|
||||
}
|
||||
|
||||
func TestCreateAISession(t *testing.T) {
|
||||
database := newTestDB(t)
|
||||
service := NewSessionService(database)
|
||||
|
||||
Reference in New Issue
Block a user