diff --git a/internal/crypto/crypto.go b/internal/crypto/crypto.go new file mode 100644 index 0000000..5418a3e --- /dev/null +++ b/internal/crypto/crypto.go @@ -0,0 +1,54 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "fmt" + "io" +) + +// Cipher performs AES-256-GCM encryption of provider secrets. +type Cipher struct { + gcm cipher.AEAD +} + +func NewCipher(key []byte) (*Cipher, error) { + if len(key) != 32 { + return nil, fmt.Errorf("crypto: key must be 32 bytes, got %d", len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return &Cipher{gcm: gcm}, nil +} + +// Encrypt returns base64(nonce‖ciphertext). +func (c *Cipher) Encrypt(plaintext []byte) (string, error) { + nonce := make([]byte, c.gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + sealed := c.gcm.Seal(nonce, nonce, plaintext, nil) + return base64.StdEncoding.EncodeToString(sealed), nil +} + +// Decrypt reverses Encrypt. +func (c *Cipher) Decrypt(enc string) ([]byte, error) { + raw, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return nil, fmt.Errorf("crypto: invalid base64: %w", err) + } + ns := c.gcm.NonceSize() + if len(raw) < ns { + return nil, fmt.Errorf("crypto: ciphertext too short") + } + nonce, ct := raw[:ns], raw[ns:] + return c.gcm.Open(nil, nonce, ct, nil) +} diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..c93b017 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,64 @@ +package crypto + +import ( + "bytes" + "testing" +) + +func key32() []byte { + k := make([]byte, 32) + for i := range k { + k[i] = byte(i + 1) + } + return k +} + +func TestEncryptDecryptRoundTrip(t *testing.T) { + c, err := NewCipher(key32()) + if err != nil { + t.Fatal(err) + } + plain := []byte("selectel-api-secret-token") + enc, err := c.Encrypt(plain) + if err != nil { + t.Fatal(err) + } + if enc == string(plain) { + t.Fatal("ciphertext must differ from plaintext") + } + dec, err := c.Decrypt(enc) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(dec, plain) { + t.Fatalf("round-trip mismatch: %q != %q", dec, plain) + } +} + +func TestEncryptNonDeterministic(t *testing.T) { + c, _ := NewCipher(key32()) + a, _ := c.Encrypt([]byte("same")) + b, _ := c.Encrypt([]byte("same")) + if a == b { + t.Fatal("nonce must randomize ciphertext") + } +} + +func TestDecryptTamperFails(t *testing.T) { + c, _ := NewCipher(key32()) + enc, _ := c.Encrypt([]byte("data")) + // испортить последний символ base64 + tampered := enc[:len(enc)-1] + "A" + if tampered == enc { + tampered = enc[:len(enc)-1] + "B" + } + if _, err := c.Decrypt(tampered); err == nil { + t.Fatal("GCM must reject tampered ciphertext") + } +} + +func TestNewCipherRejectsBadKey(t *testing.T) { + if _, err := NewCipher([]byte("short")); err == nil { + t.Fatal("expected error for non-32-byte key") + } +}