feat(crypt): add LTHN, ChaCha20, RSA, PGP primitives (port from Enchantrix) (#346) (#354)

Co-authored-by: Claude <developers@lethean.io>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vi 2026-02-05 20:30:28 +00:00 committed by GitHub
parent 7718ad5e30
commit 0413c359b3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 930 additions and 0 deletions

View file

@ -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
}

View file

@ -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)
}

94
pkg/crypt/lthn/lthn.go Normal file
View file

@ -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)
}

View file

@ -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)
}

230
pkg/crypt/pgp/pgp.go Normal file
View file

@ -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
}

164
pkg/crypt/pgp/pgp_test.go Normal file
View file

@ -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)
}

101
pkg/crypt/rsa/rsa.go Normal file
View file

@ -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
}

89
pkg/crypt/rsa/rsa_test.go Normal file
View file

@ -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)
}
}