Przeglądaj źródła

feat(telegram): AES-GCM crypto helper for 2FA at-rest encryption

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dot 2 tygodni temu
rodzic
commit
393fb87e16
2 zmienionych plików z 114 dodań i 0 usunięć
  1. 59 0
      internal/telegram/crypto.go
  2. 55 0
      internal/telegram/crypto_test.go

+ 59 - 0
internal/telegram/crypto.go

@@ -0,0 +1,59 @@
+package telegram
+
+import (
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"encoding/base64"
+	"errors"
+	"fmt"
+)
+
+// Crypto wraps AES-GCM with a fixed 32-byte key and random 12-byte nonces.
+// Used for encrypting 2FA passwords at rest.
+type Crypto struct {
+	gcm cipher.AEAD
+}
+
+// NewCrypto builds a Crypto from a base64-encoded 32-byte key.
+func NewCrypto(b64Key string) (*Crypto, error) {
+	key, err := base64.StdEncoding.DecodeString(b64Key)
+	if err != nil {
+		return nil, fmt.Errorf("decode secret key: %w", err)
+	}
+	if len(key) != 32 {
+		return nil, fmt.Errorf("secret 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 &Crypto{gcm: gcm}, nil
+}
+
+// Encrypt returns [nonce || ciphertext || tag].
+func (c *Crypto) Encrypt(plain string) ([]byte, error) {
+	nonce := make([]byte, c.gcm.NonceSize())
+	if _, err := rand.Read(nonce); err != nil {
+		return nil, err
+	}
+	return c.gcm.Seal(nonce, nonce, []byte(plain), nil), nil
+}
+
+// Decrypt expects the [nonce || ciphertext || tag] layout produced by Encrypt.
+func (c *Crypto) Decrypt(blob []byte) (string, error) {
+	ns := c.gcm.NonceSize()
+	if len(blob) < ns {
+		return "", errors.New("ciphertext too short")
+	}
+	nonce, ct := blob[:ns], blob[ns:]
+	pt, err := c.gcm.Open(nil, nonce, ct, nil)
+	if err != nil {
+		return "", err
+	}
+	return string(pt), nil
+}

+ 55 - 0
internal/telegram/crypto_test.go

@@ -0,0 +1,55 @@
+package telegram
+
+import (
+	"encoding/base64"
+	"testing"
+)
+
+func mustKey(t *testing.T) string {
+	t.Helper()
+	// 32 zero-bytes → stable base64 for test determinism.
+	return base64.StdEncoding.EncodeToString(make([]byte, 32))
+}
+
+func TestCryptoRoundTrip(t *testing.T) {
+	c, err := NewCrypto(mustKey(t))
+	if err != nil {
+		t.Fatal(err)
+	}
+	ct, err := c.Encrypt("xing")
+	if err != nil {
+		t.Fatal(err)
+	}
+	pt, err := c.Decrypt(ct)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if pt != "xing" {
+		t.Errorf("roundtrip got %q want %q", pt, "xing")
+	}
+}
+
+func TestCryptoRejectsShortKey(t *testing.T) {
+	short := base64.StdEncoding.EncodeToString(make([]byte, 16))
+	if _, err := NewCrypto(short); err == nil {
+		t.Error("expected err for 16-byte key")
+	}
+}
+
+func TestCryptoRejectsTamperedCiphertext(t *testing.T) {
+	c, _ := NewCrypto(mustKey(t))
+	ct, _ := c.Encrypt("secret")
+	ct[len(ct)-1] ^= 0xFF
+	if _, err := c.Decrypt(ct); err == nil {
+		t.Error("expected tamper detection")
+	}
+}
+
+func TestCryptoNonceRandomness(t *testing.T) {
+	c, _ := NewCrypto(mustKey(t))
+	a, _ := c.Encrypt("same")
+	b, _ := c.Encrypt("same")
+	if string(a) == string(b) {
+		t.Error("identical ciphertexts imply nonce reuse")
+	}
+}