// Package crypto provides AES-256-GCM symmetric encryption for sensitive // secret fields persisted to the database (e.g. git credentials, LLM API keys). // // The master key is supplied by the caller (see config.MasterKey) and never // stored alongside the ciphertext. package crypto import ( "crypto/aes" "crypto/cipher" "crypto/rand" "errors" "fmt" "io" ) // Encryptor 用 AES-256-GCM 加解密 secret。 // 密文格式:nonce(12) || ciphertext || tag。 type Encryptor struct { gcm cipher.AEAD } // NewEncryptor constructs an Encryptor from a 32-byte (AES-256) key. // Any other key length yields an error rather than silently downgrading. func NewEncryptor(key []byte) (*Encryptor, error) { if len(key) != 32 { return nil, fmt.Errorf("key must be 32 bytes (got %d)", len(key)) } block, err := aes.NewCipher(key) if err != nil { return nil, fmt.Errorf("aes new cipher: %w", err) } gcm, err := cipher.NewGCM(block) if err != nil { return nil, fmt.Errorf("new gcm: %w", err) } return &Encryptor{gcm: gcm}, nil } // Encrypt seals plaintext with a fresh random nonce and returns // nonce || ciphertext || tag. func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) { nonce := make([]byte, e.gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, fmt.Errorf("rand nonce: %w", err) } // Seal 把 nonce 作为 dst 前缀写入 return e.gcm.Seal(nonce, nonce, plaintext, nil), nil } // Decrypt verifies and opens a ciphertext produced by Encrypt. // It returns an error if the input is too short or authentication fails. func (e *Encryptor) Decrypt(ciphertext []byte) ([]byte, error) { if len(ciphertext) < e.gcm.NonceSize() { return nil, errors.New("ciphertext too short") } nonce, ct := ciphertext[:e.gcm.NonceSize()], ciphertext[e.gcm.NonceSize():] pt, err := e.gcm.Open(nil, nonce, ct, nil) if err != nil { return nil, fmt.Errorf("gcm open: %w", err) } return pt, nil }