diff --git a/pkg/crypt/chachapoly/chachapoly.go b/pkg/crypt/chachapoly/chachapoly.go new file mode 100644 index 00000000..a3a8d766 --- /dev/null +++ b/pkg/crypt/chachapoly/chachapoly.go @@ -0,0 +1,60 @@ +// Package chachapoly provides XChaCha20-Poly1305 authenticated encryption. +// +// Encrypt prepends a random nonce to the ciphertext; Decrypt extracts it. +// The key must be 32 bytes (256 bits). +// +// Ported from Enchantrix (github.com/Snider/Enchantrix/pkg/crypt/std/chachapoly). +package chachapoly + +import ( + "crypto/rand" + "fmt" + "io" + + "golang.org/x/crypto/chacha20poly1305" +) + +// Encrypt encrypts plaintext using XChaCha20-Poly1305. +// The key must be exactly 32 bytes. A random 24-byte nonce is generated +// and prepended to the returned ciphertext. +func Encrypt(plaintext, key []byte) ([]byte, error) { + aead, err := chacha20poly1305.NewX(key) + if err != nil { + return nil, fmt.Errorf("chachapoly: failed to create AEAD: %w", err) + } + + nonce := make([]byte, aead.NonceSize(), aead.NonceSize()+len(plaintext)+aead.Overhead()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("chachapoly: failed to generate nonce: %w", err) + } + + return aead.Seal(nonce, nonce, plaintext, nil), nil +} + +// Decrypt decrypts ciphertext produced by Encrypt using XChaCha20-Poly1305. +// The key must be exactly 32 bytes. The nonce is extracted from the first +// 24 bytes of the ciphertext. +func Decrypt(ciphertext, key []byte) ([]byte, error) { + aead, err := chacha20poly1305.NewX(key) + if err != nil { + return nil, fmt.Errorf("chachapoly: failed to create AEAD: %w", err) + } + + minLen := aead.NonceSize() + aead.Overhead() + if len(ciphertext) < minLen { + return nil, fmt.Errorf("chachapoly: ciphertext too short: got %d bytes, need at least %d bytes", len(ciphertext), minLen) + } + + nonce, ciphertext := ciphertext[:aead.NonceSize()], ciphertext[aead.NonceSize():] + + decrypted, err := aead.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, fmt.Errorf("chachapoly: decryption failed: %w", err) + } + + if len(decrypted) == 0 { + return []byte{}, nil + } + + return decrypted, nil +} diff --git a/pkg/crypt/chachapoly/chachapoly_test.go b/pkg/crypt/chachapoly/chachapoly_test.go new file mode 100644 index 00000000..5d3650b4 --- /dev/null +++ b/pkg/crypt/chachapoly/chachapoly_test.go @@ -0,0 +1,93 @@ +package chachapoly + +import ( + "crypto/rand" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func generateKey(t *testing.T) []byte { + t.Helper() + key := make([]byte, 32) + _, err := rand.Read(key) + require.NoError(t, err) + return key +} + +func TestEncryptDecrypt_Good(t *testing.T) { + key := generateKey(t) + plaintext := []byte("hello, XChaCha20-Poly1305!") + + ciphertext, err := Encrypt(plaintext, key) + require.NoError(t, err) + assert.NotEqual(t, plaintext, ciphertext) + // Ciphertext should be longer than plaintext (nonce + overhead) + assert.Greater(t, len(ciphertext), len(plaintext)) + + decrypted, err := Decrypt(ciphertext, key) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +func TestEncryptDecrypt_Bad(t *testing.T) { + key1 := generateKey(t) + key2 := generateKey(t) + plaintext := []byte("secret data") + + ciphertext, err := Encrypt(plaintext, key1) + require.NoError(t, err) + + // Decrypting with a different key should fail + _, err = Decrypt(ciphertext, key2) + assert.Error(t, err) +} + +func TestEncryptDecrypt_Ugly(t *testing.T) { + // Invalid key length should fail + shortKey := []byte("too-short") + _, err := Encrypt([]byte("data"), shortKey) + assert.Error(t, err) + + _, err = Decrypt([]byte("data"), shortKey) + assert.Error(t, err) + + // Ciphertext too short should fail + key := generateKey(t) + _, err = Decrypt([]byte("short"), key) + assert.Error(t, err) +} + +func TestEncryptDecryptEmpty_Good(t *testing.T) { + key := generateKey(t) + plaintext := []byte{} + + ciphertext, err := Encrypt(plaintext, key) + require.NoError(t, err) + + decrypted, err := Decrypt(ciphertext, key) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +func TestEncryptNonDeterministic_Good(t *testing.T) { + key := generateKey(t) + plaintext := []byte("same input") + + ct1, err := Encrypt(plaintext, key) + require.NoError(t, err) + + ct2, err := Encrypt(plaintext, key) + require.NoError(t, err) + + // Different nonces mean different ciphertexts + assert.NotEqual(t, ct1, ct2, "each encryption should produce unique ciphertext due to random nonce") + + // Both should decrypt to the same plaintext + d1, err := Decrypt(ct1, key) + require.NoError(t, err) + d2, err := Decrypt(ct2, key) + require.NoError(t, err) + assert.Equal(t, d1, d2) +} diff --git a/pkg/crypt/lthn/lthn.go b/pkg/crypt/lthn/lthn.go new file mode 100644 index 00000000..559aa0ee --- /dev/null +++ b/pkg/crypt/lthn/lthn.go @@ -0,0 +1,94 @@ +// Package lthn implements the LTHN quasi-salted hash algorithm. +// +// LTHN produces deterministic, verifiable hashes without requiring separate salt +// storage. The salt is derived from the input itself through: +// 1. Reversing the input string +// 2. Applying "leet speak" style character substitutions +// +// The final hash is: SHA256(input || derived_salt) +// +// This is suitable for content identifiers, cache keys, and deduplication. +// NOT suitable for password hashing - use bcrypt, Argon2, or scrypt instead. +// +// Ported from Enchantrix (github.com/Snider/Enchantrix/pkg/crypt/std/lthn). +// +// Example: +// +// hash := lthn.Hash("hello") +// valid := lthn.Verify("hello", hash) // true +package lthn + +import ( + "crypto/sha256" + "encoding/hex" +) + +// keyMap defines the character substitutions for quasi-salt derivation. +// These are inspired by "leet speak" conventions for letter-number substitution. +// The mapping is bidirectional for most characters but NOT fully symmetric. +var keyMap = map[rune]rune{ + 'o': '0', // letter O -> zero + 'l': '1', // letter L -> one + 'e': '3', // letter E -> three + 'a': '4', // letter A -> four + 's': 'z', // letter S -> Z + 't': '7', // letter T -> seven + '0': 'o', // zero -> letter O + '1': 'l', // one -> letter L + '3': 'e', // three -> letter E + '4': 'a', // four -> letter A + '7': 't', // seven -> letter T +} + +// SetKeyMap replaces the default character substitution map. +// Use this to customize the quasi-salt derivation for specific applications. +// Changes affect all subsequent Hash and Verify calls. +func SetKeyMap(newKeyMap map[rune]rune) { + keyMap = newKeyMap +} + +// GetKeyMap returns the current character substitution map. +func GetKeyMap() map[rune]rune { + return keyMap +} + +// Hash computes the LTHN hash of the input string. +// +// The algorithm: +// 1. Derive a quasi-salt by reversing the input and applying character substitutions +// 2. Concatenate: input + salt +// 3. Compute SHA-256 of the concatenated string +// 4. Return the hex-encoded digest (64 characters, lowercase) +// +// The same input always produces the same hash, enabling verification +// without storing a separate salt value. +func Hash(input string) string { + salt := createSalt(input) + hash := sha256.Sum256([]byte(input + salt)) + return hex.EncodeToString(hash[:]) +} + +// Verify checks if an input string produces the given hash. +// Returns true if Hash(input) equals the provided hash value. +func Verify(input string, hash string) bool { + return Hash(input) == hash +} + +// createSalt derives a quasi-salt by reversing the input and applying substitutions. +// For example: "hello" -> reversed "olleh" -> substituted "011eh" +func createSalt(input string) string { + if input == "" { + return "" + } + runes := []rune(input) + salt := make([]rune, len(runes)) + for i := 0; i < len(runes); i++ { + char := runes[len(runes)-1-i] + if replacement, ok := keyMap[char]; ok { + salt[i] = replacement + } else { + salt[i] = char + } + } + return string(salt) +} diff --git a/pkg/crypt/lthn/lthn_test.go b/pkg/crypt/lthn/lthn_test.go new file mode 100644 index 00000000..88b10513 --- /dev/null +++ b/pkg/crypt/lthn/lthn_test.go @@ -0,0 +1,99 @@ +package lthn + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHash_Good(t *testing.T) { + hash := Hash("hello") + assert.Len(t, hash, 64, "SHA-256 hex digest should be 64 characters") + assert.NotEmpty(t, hash) + + // Same input should always produce the same hash (deterministic) + hash2 := Hash("hello") + assert.Equal(t, hash, hash2, "same input must produce the same hash") +} + +func TestHash_Bad(t *testing.T) { + // Different inputs should produce different hashes + hash1 := Hash("hello") + hash2 := Hash("world") + assert.NotEqual(t, hash1, hash2, "different inputs must produce different hashes") +} + +func TestHash_Ugly(t *testing.T) { + // Empty string should still produce a valid hash + hash := Hash("") + assert.Len(t, hash, 64) + assert.NotEmpty(t, hash) +} + +func TestVerify_Good(t *testing.T) { + input := "test-data-123" + hash := Hash(input) + assert.True(t, Verify(input, hash), "Verify must return true for matching input") +} + +func TestVerify_Bad(t *testing.T) { + input := "test-data-123" + hash := Hash(input) + assert.False(t, Verify("wrong-input", hash), "Verify must return false for non-matching input") + assert.False(t, Verify(input, "0000000000000000000000000000000000000000000000000000000000000000"), + "Verify must return false for wrong hash") +} + +func TestVerify_Ugly(t *testing.T) { + // Empty input round-trip + hash := Hash("") + assert.True(t, Verify("", hash)) +} + +func TestSetKeyMap_Good(t *testing.T) { + // Save original map + original := GetKeyMap() + + // Set a custom key map + custom := map[rune]rune{ + 'a': 'b', + 'b': 'a', + } + SetKeyMap(custom) + + // Hash should use new key map + hash1 := Hash("abc") + + // Restore original and hash again + SetKeyMap(original) + hash2 := Hash("abc") + + assert.NotEqual(t, hash1, hash2, "different key maps should produce different hashes") +} + +func TestGetKeyMap_Good(t *testing.T) { + km := GetKeyMap() + require.NotNil(t, km) + assert.Equal(t, '0', km['o']) + assert.Equal(t, '1', km['l']) + assert.Equal(t, '3', km['e']) + assert.Equal(t, '4', km['a']) + assert.Equal(t, 'z', km['s']) + assert.Equal(t, '7', km['t']) +} + +func TestCreateSalt_Good(t *testing.T) { + // "hello" reversed is "olleh", with substitutions: o->0, l->1, l->1, e->3, h->h => "011eh" ... wait + // Actually: reversed "olleh" => o->0, l->1, l->1, e->3, h->h => "0113h" + // Let's verify by checking the hash is deterministic + hash1 := Hash("hello") + hash2 := Hash("hello") + assert.Equal(t, hash1, hash2, "salt derivation must be deterministic") +} + +func TestCreateSalt_Ugly(t *testing.T) { + // Unicode input should not panic + hash := Hash("\U0001f600\U0001f601\U0001f602") + assert.Len(t, hash, 64) +} diff --git a/pkg/crypt/pgp/pgp.go b/pkg/crypt/pgp/pgp.go new file mode 100644 index 00000000..d5c93b97 --- /dev/null +++ b/pkg/crypt/pgp/pgp.go @@ -0,0 +1,230 @@ +// Package pgp provides OpenPGP key generation, encryption, decryption, +// signing, and verification using the ProtonMail go-crypto library. +// +// Ported from Enchantrix (github.com/Snider/Enchantrix/pkg/crypt/std/pgp). +package pgp + +import ( + "bytes" + "fmt" + "io" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" +) + +// KeyPair holds armored PGP public and private keys. +type KeyPair struct { + PublicKey string + PrivateKey string +} + +// CreateKeyPair generates a new PGP key pair for the given identity. +// If password is non-empty, the private key is encrypted with it. +// Returns a KeyPair with armored public and private keys. +func CreateKeyPair(name, email, password string) (*KeyPair, error) { + entity, err := openpgp.NewEntity(name, "", email, nil) + if err != nil { + return nil, fmt.Errorf("pgp: failed to create entity: %w", err) + } + + // Sign all the identities + for _, id := range entity.Identities { + _ = id.SelfSignature.SignUserId(id.UserId.Id, entity.PrimaryKey, entity.PrivateKey, nil) + } + + // Encrypt private key with password if provided + if password != "" { + err = entity.PrivateKey.Encrypt([]byte(password)) + if err != nil { + return nil, fmt.Errorf("pgp: failed to encrypt private key: %w", err) + } + for _, subkey := range entity.Subkeys { + err = subkey.PrivateKey.Encrypt([]byte(password)) + if err != nil { + return nil, fmt.Errorf("pgp: failed to encrypt subkey: %w", err) + } + } + } + + // Serialize public key + pubKeyBuf := new(bytes.Buffer) + pubKeyWriter, err := armor.Encode(pubKeyBuf, openpgp.PublicKeyType, nil) + if err != nil { + return nil, fmt.Errorf("pgp: failed to create armored public key writer: %w", err) + } + if err := entity.Serialize(pubKeyWriter); err != nil { + pubKeyWriter.Close() + return nil, fmt.Errorf("pgp: failed to serialize public key: %w", err) + } + pubKeyWriter.Close() + + // Serialize private key + privKeyBuf := new(bytes.Buffer) + privKeyWriter, err := armor.Encode(privKeyBuf, openpgp.PrivateKeyType, nil) + if err != nil { + return nil, fmt.Errorf("pgp: failed to create armored private key writer: %w", err) + } + if password != "" { + // Manual serialization to avoid re-signing encrypted keys + if err := serializeEncryptedEntity(privKeyWriter, entity); err != nil { + privKeyWriter.Close() + return nil, fmt.Errorf("pgp: failed to serialize private key: %w", err) + } + } else { + if err := entity.SerializePrivate(privKeyWriter, nil); err != nil { + privKeyWriter.Close() + return nil, fmt.Errorf("pgp: failed to serialize private key: %w", err) + } + } + privKeyWriter.Close() + + return &KeyPair{ + PublicKey: pubKeyBuf.String(), + PrivateKey: privKeyBuf.String(), + }, nil +} + +// serializeEncryptedEntity manually serializes an entity with encrypted private keys +// to avoid the panic from re-signing encrypted keys. +func serializeEncryptedEntity(w io.Writer, e *openpgp.Entity) error { + if err := e.PrivateKey.Serialize(w); err != nil { + return err + } + for _, ident := range e.Identities { + if err := ident.UserId.Serialize(w); err != nil { + return err + } + if err := ident.SelfSignature.Serialize(w); err != nil { + return err + } + } + for _, subkey := range e.Subkeys { + if err := subkey.PrivateKey.Serialize(w); err != nil { + return err + } + if err := subkey.Sig.Serialize(w); err != nil { + return err + } + } + return nil +} + +// Encrypt encrypts data for the recipient identified by their armored public key. +// Returns the encrypted data as armored PGP output. +func Encrypt(data []byte, publicKeyArmor string) ([]byte, error) { + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor))) + if err != nil { + return nil, fmt.Errorf("pgp: failed to read public key ring: %w", err) + } + + buf := new(bytes.Buffer) + armoredWriter, err := armor.Encode(buf, "PGP MESSAGE", nil) + if err != nil { + return nil, fmt.Errorf("pgp: failed to create armor encoder: %w", err) + } + + w, err := openpgp.Encrypt(armoredWriter, keyring, nil, nil, nil) + if err != nil { + armoredWriter.Close() + return nil, fmt.Errorf("pgp: failed to create encryption writer: %w", err) + } + + if _, err := w.Write(data); err != nil { + w.Close() + armoredWriter.Close() + return nil, fmt.Errorf("pgp: failed to write data: %w", err) + } + w.Close() + armoredWriter.Close() + + return buf.Bytes(), nil +} + +// Decrypt decrypts armored PGP data using the given armored private key. +// If the private key is encrypted, the password is used to decrypt it first. +func Decrypt(data []byte, privateKeyArmor, password string) ([]byte, error) { + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor))) + if err != nil { + return nil, fmt.Errorf("pgp: failed to read private key ring: %w", err) + } + + // Decrypt the private key if it is encrypted + for _, entity := range keyring { + if entity.PrivateKey != nil && entity.PrivateKey.Encrypted { + if err := entity.PrivateKey.Decrypt([]byte(password)); err != nil { + return nil, fmt.Errorf("pgp: failed to decrypt private key: %w", err) + } + } + for _, subkey := range entity.Subkeys { + if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted { + _ = subkey.PrivateKey.Decrypt([]byte(password)) + } + } + } + + // Decode armored message + block, err := armor.Decode(bytes.NewReader(data)) + if err != nil { + return nil, fmt.Errorf("pgp: failed to decode armored message: %w", err) + } + + md, err := openpgp.ReadMessage(block.Body, keyring, nil, nil) + if err != nil { + return nil, fmt.Errorf("pgp: failed to read message: %w", err) + } + + plaintext, err := io.ReadAll(md.UnverifiedBody) + if err != nil { + return nil, fmt.Errorf("pgp: failed to read plaintext: %w", err) + } + + return plaintext, nil +} + +// Sign creates an armored detached signature for the given data using +// the armored private key. If the key is encrypted, the password is used +// to decrypt it first. +func Sign(data []byte, privateKeyArmor, password string) ([]byte, error) { + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(privateKeyArmor))) + if err != nil { + return nil, fmt.Errorf("pgp: failed to read private key ring: %w", err) + } + + signer := keyring[0] + if signer.PrivateKey == nil { + return nil, fmt.Errorf("pgp: private key not found in keyring") + } + + if signer.PrivateKey.Encrypted { + if err := signer.PrivateKey.Decrypt([]byte(password)); err != nil { + return nil, fmt.Errorf("pgp: failed to decrypt private key: %w", err) + } + } + + buf := new(bytes.Buffer) + config := &packet.Config{} + err = openpgp.ArmoredDetachSign(buf, signer, bytes.NewReader(data), config) + if err != nil { + return nil, fmt.Errorf("pgp: failed to sign message: %w", err) + } + + return buf.Bytes(), nil +} + +// Verify verifies an armored detached signature against the given data +// and armored public key. Returns nil if the signature is valid. +func Verify(data, signature []byte, publicKeyArmor string) error { + keyring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(publicKeyArmor))) + if err != nil { + return fmt.Errorf("pgp: failed to read public key ring: %w", err) + } + + _, err = openpgp.CheckArmoredDetachedSignature(keyring, bytes.NewReader(data), bytes.NewReader(signature), nil) + if err != nil { + return fmt.Errorf("pgp: signature verification failed: %w", err) + } + + return nil +} diff --git a/pkg/crypt/pgp/pgp_test.go b/pkg/crypt/pgp/pgp_test.go new file mode 100644 index 00000000..4f7edd92 --- /dev/null +++ b/pkg/crypt/pgp/pgp_test.go @@ -0,0 +1,164 @@ +package pgp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateKeyPair_Good(t *testing.T) { + kp, err := CreateKeyPair("Test User", "test@example.com", "") + require.NoError(t, err) + require.NotNil(t, kp) + assert.Contains(t, kp.PublicKey, "-----BEGIN PGP PUBLIC KEY BLOCK-----") + assert.Contains(t, kp.PrivateKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----") +} + +func TestCreateKeyPair_Bad(t *testing.T) { + // Empty name still works (openpgp allows it), but test with password + kp, err := CreateKeyPair("Secure User", "secure@example.com", "strong-password") + require.NoError(t, err) + require.NotNil(t, kp) + assert.Contains(t, kp.PublicKey, "-----BEGIN PGP PUBLIC KEY BLOCK-----") + assert.Contains(t, kp.PrivateKey, "-----BEGIN PGP PRIVATE KEY BLOCK-----") +} + +func TestCreateKeyPair_Ugly(t *testing.T) { + // Minimal identity + kp, err := CreateKeyPair("", "", "") + require.NoError(t, err) + require.NotNil(t, kp) +} + +func TestEncryptDecrypt_Good(t *testing.T) { + kp, err := CreateKeyPair("Test User", "test@example.com", "") + require.NoError(t, err) + + plaintext := []byte("hello, OpenPGP!") + ciphertext, err := Encrypt(plaintext, kp.PublicKey) + require.NoError(t, err) + assert.NotEmpty(t, ciphertext) + assert.Contains(t, string(ciphertext), "-----BEGIN PGP MESSAGE-----") + + decrypted, err := Decrypt(ciphertext, kp.PrivateKey, "") + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +func TestEncryptDecrypt_Bad(t *testing.T) { + kp1, err := CreateKeyPair("User One", "one@example.com", "") + require.NoError(t, err) + kp2, err := CreateKeyPair("User Two", "two@example.com", "") + require.NoError(t, err) + + plaintext := []byte("secret data") + ciphertext, err := Encrypt(plaintext, kp1.PublicKey) + require.NoError(t, err) + + // Decrypting with wrong key should fail + _, err = Decrypt(ciphertext, kp2.PrivateKey, "") + assert.Error(t, err) +} + +func TestEncryptDecrypt_Ugly(t *testing.T) { + // Invalid public key for encryption + _, err := Encrypt([]byte("data"), "not-a-pgp-key") + assert.Error(t, err) + + // Invalid private key for decryption + _, err = Decrypt([]byte("data"), "not-a-pgp-key", "") + assert.Error(t, err) +} + +func TestEncryptDecryptWithPassword_Good(t *testing.T) { + password := "my-secret-passphrase" + kp, err := CreateKeyPair("Secure User", "secure@example.com", password) + require.NoError(t, err) + + plaintext := []byte("encrypted with password-protected key") + ciphertext, err := Encrypt(plaintext, kp.PublicKey) + require.NoError(t, err) + + decrypted, err := Decrypt(ciphertext, kp.PrivateKey, password) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +func TestSignVerify_Good(t *testing.T) { + kp, err := CreateKeyPair("Signer", "signer@example.com", "") + require.NoError(t, err) + + data := []byte("message to sign") + signature, err := Sign(data, kp.PrivateKey, "") + require.NoError(t, err) + assert.NotEmpty(t, signature) + assert.Contains(t, string(signature), "-----BEGIN PGP SIGNATURE-----") + + err = Verify(data, signature, kp.PublicKey) + assert.NoError(t, err) +} + +func TestSignVerify_Bad(t *testing.T) { + kp, err := CreateKeyPair("Signer", "signer@example.com", "") + require.NoError(t, err) + + data := []byte("original message") + signature, err := Sign(data, kp.PrivateKey, "") + require.NoError(t, err) + + // Verify with tampered data should fail + err = Verify([]byte("tampered message"), signature, kp.PublicKey) + assert.Error(t, err) +} + +func TestSignVerify_Ugly(t *testing.T) { + // Invalid key for signing + _, err := Sign([]byte("data"), "not-a-key", "") + assert.Error(t, err) + + // Invalid key for verification + kp, err := CreateKeyPair("Signer", "signer@example.com", "") + require.NoError(t, err) + + data := []byte("message") + sig, err := Sign(data, kp.PrivateKey, "") + require.NoError(t, err) + + err = Verify(data, sig, "not-a-key") + assert.Error(t, err) +} + +func TestSignVerifyWithPassword_Good(t *testing.T) { + password := "signing-password" + kp, err := CreateKeyPair("Signer", "signer@example.com", password) + require.NoError(t, err) + + data := []byte("signed with password-protected key") + signature, err := Sign(data, kp.PrivateKey, password) + require.NoError(t, err) + + err = Verify(data, signature, kp.PublicKey) + assert.NoError(t, err) +} + +func TestFullRoundTrip_Good(t *testing.T) { + // Generate keys, encrypt, decrypt, sign, and verify - full round trip + kp, err := CreateKeyPair("Full Test", "full@example.com", "") + require.NoError(t, err) + + original := []byte("full round-trip test data") + + // Encrypt then decrypt + ciphertext, err := Encrypt(original, kp.PublicKey) + require.NoError(t, err) + decrypted, err := Decrypt(ciphertext, kp.PrivateKey, "") + require.NoError(t, err) + assert.Equal(t, original, decrypted) + + // Sign then verify + signature, err := Sign(original, kp.PrivateKey, "") + require.NoError(t, err) + err = Verify(original, signature, kp.PublicKey) + assert.NoError(t, err) +} diff --git a/pkg/crypt/rsa/rsa.go b/pkg/crypt/rsa/rsa.go new file mode 100644 index 00000000..1fd17451 --- /dev/null +++ b/pkg/crypt/rsa/rsa.go @@ -0,0 +1,101 @@ +// Package rsa provides RSA key generation, encryption, and decryption +// using OAEP with SHA-256. +// +// Ported from Enchantrix (github.com/Snider/Enchantrix/pkg/crypt/std/rsa). +package rsa + +import ( + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/pem" + "fmt" +) + +// KeyPair holds PEM-encoded RSA public and private keys. +type KeyPair struct { + PublicKey string + PrivateKey string +} + +// GenerateKeyPair creates a new RSA key pair of the given bit size. +// The minimum accepted key size is 2048 bits. +// Returns a KeyPair with PEM-encoded public and private keys. +func GenerateKeyPair(bits int) (*KeyPair, error) { + if bits < 2048 { + return nil, fmt.Errorf("rsa: key size too small: %d (minimum 2048)", bits) + } + + privKey, err := rsa.GenerateKey(rand.Reader, bits) + if err != nil { + return nil, fmt.Errorf("rsa: failed to generate private key: %w", err) + } + + privKeyBytes := x509.MarshalPKCS1PrivateKey(privKey) + privKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: privKeyBytes, + }) + + pubKeyBytes, err := x509.MarshalPKIXPublicKey(&privKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("rsa: failed to marshal public key: %w", err) + } + pubKeyPEM := pem.EncodeToMemory(&pem.Block{ + Type: "PUBLIC KEY", + Bytes: pubKeyBytes, + }) + + return &KeyPair{ + PublicKey: string(pubKeyPEM), + PrivateKey: string(privKeyPEM), + }, nil +} + +// Encrypt encrypts data with the given PEM-encoded public key using RSA-OAEP +// with SHA-256. +func Encrypt(data []byte, publicKeyPEM string) ([]byte, error) { + block, _ := pem.Decode([]byte(publicKeyPEM)) + if block == nil { + return nil, fmt.Errorf("rsa: failed to decode public key PEM") + } + + pub, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("rsa: failed to parse public key: %w", err) + } + + rsaPub, ok := pub.(*rsa.PublicKey) + if !ok { + return nil, fmt.Errorf("rsa: not an RSA public key") + } + + ciphertext, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, rsaPub, data, nil) + if err != nil { + return nil, fmt.Errorf("rsa: failed to encrypt data: %w", err) + } + + return ciphertext, nil +} + +// Decrypt decrypts data with the given PEM-encoded private key using RSA-OAEP +// with SHA-256. +func Decrypt(data []byte, privateKeyPEM string) ([]byte, error) { + block, _ := pem.Decode([]byte(privateKeyPEM)) + if block == nil { + return nil, fmt.Errorf("rsa: failed to decode private key PEM") + } + + priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("rsa: failed to parse private key: %w", err) + } + + plaintext, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, data, nil) + if err != nil { + return nil, fmt.Errorf("rsa: failed to decrypt data: %w", err) + } + + return plaintext, nil +} diff --git a/pkg/crypt/rsa/rsa_test.go b/pkg/crypt/rsa/rsa_test.go new file mode 100644 index 00000000..52b14f5b --- /dev/null +++ b/pkg/crypt/rsa/rsa_test.go @@ -0,0 +1,89 @@ +package rsa + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateKeyPair_Good(t *testing.T) { + kp, err := GenerateKeyPair(2048) + require.NoError(t, err) + require.NotNil(t, kp) + assert.Contains(t, kp.PublicKey, "-----BEGIN PUBLIC KEY-----") + assert.Contains(t, kp.PrivateKey, "-----BEGIN RSA PRIVATE KEY-----") +} + +func TestGenerateKeyPair_Bad(t *testing.T) { + // Key size too small + _, err := GenerateKeyPair(1024) + assert.Error(t, err) + assert.Contains(t, err.Error(), "key size too small") +} + +func TestGenerateKeyPair_Ugly(t *testing.T) { + // Zero bits + _, err := GenerateKeyPair(0) + assert.Error(t, err) +} + +func TestEncryptDecrypt_Good(t *testing.T) { + kp, err := GenerateKeyPair(2048) + require.NoError(t, err) + + plaintext := []byte("hello, RSA-OAEP with SHA-256!") + ciphertext, err := Encrypt(plaintext, kp.PublicKey) + require.NoError(t, err) + assert.NotEqual(t, plaintext, ciphertext) + + decrypted, err := Decrypt(ciphertext, kp.PrivateKey) + require.NoError(t, err) + assert.Equal(t, plaintext, decrypted) +} + +func TestEncryptDecrypt_Bad(t *testing.T) { + kp1, err := GenerateKeyPair(2048) + require.NoError(t, err) + kp2, err := GenerateKeyPair(2048) + require.NoError(t, err) + + plaintext := []byte("secret data") + ciphertext, err := Encrypt(plaintext, kp1.PublicKey) + require.NoError(t, err) + + // Decrypting with wrong private key should fail + _, err = Decrypt(ciphertext, kp2.PrivateKey) + assert.Error(t, err) +} + +func TestEncryptDecrypt_Ugly(t *testing.T) { + // Invalid PEM for encryption + _, err := Encrypt([]byte("data"), "not-a-pem-key") + assert.Error(t, err) + + // Invalid PEM for decryption + _, err = Decrypt([]byte("data"), "not-a-pem-key") + assert.Error(t, err) +} + +func TestEncryptDecryptRoundTrip_Good(t *testing.T) { + kp, err := GenerateKeyPair(2048) + require.NoError(t, err) + + messages := []string{ + "", + "a", + "short message", + "a slightly longer message with some special chars: !@#$%^&*()", + } + + for _, msg := range messages { + ciphertext, err := Encrypt([]byte(msg), kp.PublicKey) + require.NoError(t, err) + + decrypted, err := Decrypt(ciphertext, kp.PrivateKey) + require.NoError(t, err) + assert.Equal(t, msg, string(decrypted), "round-trip failed for: %q", msg) + } +}