Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 338 additions & 0 deletions pkg/enchantrix/crypto_sigil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,338 @@
package enchantrix

import (
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"io"

"golang.org/x/crypto/chacha20poly1305"
)

var (
// ErrInvalidKey is returned when the encryption key is invalid.
ErrInvalidKey = errors.New("enchantrix: invalid key size, must be 32 bytes")
// ErrCiphertextTooShort is returned when the ciphertext is too short to decrypt.
ErrCiphertextTooShort = errors.New("enchantrix: ciphertext too short")
// ErrDecryptionFailed is returned when decryption or authentication fails.
ErrDecryptionFailed = errors.New("enchantrix: decryption failed")
// ErrNoKeyConfigured is returned when no encryption key has been set.
ErrNoKeyConfigured = errors.New("enchantrix: no encryption key configured")
)

// PreObfuscator applies a reversible transformation to data before encryption.
// This ensures that raw plaintext is never sent directly to CPU encryption routines.
type PreObfuscator interface {
// Obfuscate transforms plaintext before encryption.
Obfuscate(data []byte, entropy []byte) []byte
// Deobfuscate reverses the transformation after decryption.
Deobfuscate(data []byte, entropy []byte) []byte
}

// XORObfuscator performs XOR-based obfuscation using entropy-derived key stream.
// This is a reversible transformation that ensures no cleartext patterns remain.
type XORObfuscator struct{}

// Obfuscate XORs the data with a key stream derived from the entropy.
func (x *XORObfuscator) Obfuscate(data []byte, entropy []byte) []byte {
if len(data) == 0 {
return data
}
return x.transform(data, entropy)
}

// Deobfuscate reverses the XOR transformation (XOR is symmetric).
func (x *XORObfuscator) Deobfuscate(data []byte, entropy []byte) []byte {
if len(data) == 0 {
return data
}
return x.transform(data, entropy)
}

// transform applies XOR with an entropy-derived key stream.
func (x *XORObfuscator) transform(data []byte, entropy []byte) []byte {
result := make([]byte, len(data))
keyStream := x.deriveKeyStream(entropy, len(data))
for i := range data {
result[i] = data[i] ^ keyStream[i]
}
return result
}

// deriveKeyStream creates a deterministic key stream from entropy.
func (x *XORObfuscator) deriveKeyStream(entropy []byte, length int) []byte {
stream := make([]byte, length)
h := sha256.New()

// Generate key stream in 32-byte blocks
blockNum := uint64(0)
offset := 0
for offset < length {
h.Reset()
h.Write(entropy)
var blockBytes [8]byte
binary.BigEndian.PutUint64(blockBytes[:], blockNum)
h.Write(blockBytes[:])
block := h.Sum(nil)

copyLen := len(block)
if offset+copyLen > length {
copyLen = length - offset
}
copy(stream[offset:], block[:copyLen])
offset += copyLen
blockNum++
}
return stream
}

// ShuffleMaskObfuscator applies byte-level shuffling based on entropy.
// This provides additional diffusion before encryption.
type ShuffleMaskObfuscator struct{}

// Obfuscate shuffles bytes and applies a mask derived from entropy.
func (s *ShuffleMaskObfuscator) Obfuscate(data []byte, entropy []byte) []byte {
if len(data) == 0 {
return data
}

result := make([]byte, len(data))
copy(result, data)

// Generate permutation and mask from entropy
perm := s.generatePermutation(entropy, len(data))
mask := s.deriveMask(entropy, len(data))

// Apply mask first, then shuffle
for i := range result {
result[i] ^= mask[i]
}

// Shuffle using Fisher-Yates with deterministic seed
shuffled := make([]byte, len(data))
for i, p := range perm {
shuffled[i] = result[p]
}

return shuffled
}

// Deobfuscate reverses the shuffle and mask operations.
func (s *ShuffleMaskObfuscator) Deobfuscate(data []byte, entropy []byte) []byte {
if len(data) == 0 {
return data
}

result := make([]byte, len(data))

// Generate permutation and mask from entropy
perm := s.generatePermutation(entropy, len(data))
mask := s.deriveMask(entropy, len(data))

// Unshuffle first
for i, p := range perm {
result[p] = data[i]
}

// Remove mask
for i := range result {
result[i] ^= mask[i]
}

return result
}

// generatePermutation creates a deterministic permutation from entropy.
func (s *ShuffleMaskObfuscator) generatePermutation(entropy []byte, length int) []int {
perm := make([]int, length)
for i := range perm {
perm[i] = i
}

// Use entropy to seed a deterministic shuffle
h := sha256.New()
h.Write(entropy)
h.Write([]byte("permutation"))
seed := h.Sum(nil)

// Fisher-Yates shuffle with deterministic randomness
for i := length - 1; i > 0; i-- {
h.Reset()
h.Write(seed)
var iBytes [8]byte
binary.BigEndian.PutUint64(iBytes[:], uint64(i))
h.Write(iBytes[:])
jBytes := h.Sum(nil)
j := int(binary.BigEndian.Uint64(jBytes[:8]) % uint64(i+1))
perm[i], perm[j] = perm[j], perm[i]
}
Comment on lines +159 to +169

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation of generatePermutation re-calculates a SHA256 hash on every iteration of the loop. This is inefficient and can be a performance bottleneck for large inputs. Additionally, using the modulo operator % to determine the swap index j can introduce a slight statistical bias.

A more performant approach would be to generate a single stream of bytes upfront (similar to deriveKeyStream) and use that to source the values for the shuffle. This avoids expensive hash computations inside the loop.

For example, you could generate a stream of bytes and then use it like this:

// ... after generating seed ...

// Generate a stream of bytes to use for shuffling
streamLen := (length - 1) * 8 // 8 bytes per swap
keyStream := deriveStreamFromSeed(seed, streamLen) // A new helper function

// Fisher-Yates shuffle with deterministic randomness from the stream
for i := length - 1; i > 0; i-- {
    byteOffset := (length - 1 - i) * 8
    jBytes := keyStream[byteOffset : byteOffset+8]
    j := int(binary.BigEndian.Uint64(jBytes) % uint64(i+1))
    perm[i], perm[j] = perm[j], perm[i]
}

I also recommend refactoring the stream generation logic from XORObfuscator.deriveKeyStream and ShuffleMaskObfuscator.deriveMask into a shared helper function to promote code reuse.


return perm
}

// deriveMask creates a mask byte array from entropy.
func (s *ShuffleMaskObfuscator) deriveMask(entropy []byte, length int) []byte {
mask := make([]byte, length)
h := sha256.New()

blockNum := uint64(0)
offset := 0
for offset < length {
h.Reset()
h.Write(entropy)
h.Write([]byte("mask"))
var blockBytes [8]byte
binary.BigEndian.PutUint64(blockBytes[:], blockNum)
h.Write(blockBytes[:])
block := h.Sum(nil)

copyLen := len(block)
if offset+copyLen > length {
copyLen = length - offset
}
copy(mask[offset:], block[:copyLen])
offset += copyLen
blockNum++
}
return mask
}

// ChaChaPolySigil is a Sigil that encrypts/decrypts data using ChaCha20-Poly1305.
// It applies pre-obfuscation before encryption to ensure raw plaintext never
// goes directly to CPU encryption routines.
//
// The output format is:
// [24-byte nonce][encrypted(obfuscated(plaintext))]
//
// Unlike demo implementations, the nonce is ONLY embedded in the ciphertext,
// not exposed separately in headers.
type ChaChaPolySigil struct {
Key []byte
Obfuscator PreObfuscator
randReader io.Reader // for testing injection
}

// NewChaChaPolySigil creates a new encryption sigil with the given key.
// The key must be exactly 32 bytes.
func NewChaChaPolySigil(key []byte) (*ChaChaPolySigil, error) {
if len(key) != 32 {
return nil, ErrInvalidKey
}

keyCopy := make([]byte, 32)
copy(keyCopy, key)

return &ChaChaPolySigil{
Key: keyCopy,
Obfuscator: &XORObfuscator{},
randReader: rand.Reader,
}, nil
}

// NewChaChaPolySigilWithObfuscator creates a new encryption sigil with custom obfuscator.
func NewChaChaPolySigilWithObfuscator(key []byte, obfuscator PreObfuscator) (*ChaChaPolySigil, error) {
sigil, err := NewChaChaPolySigil(key)
if err != nil {
return nil, err
}
if obfuscator != nil {
sigil.Obfuscator = obfuscator
}
return sigil, nil
}

// In encrypts the data with pre-obfuscation.
// The flow is: plaintext -> obfuscate -> encrypt
func (s *ChaChaPolySigil) In(data []byte) ([]byte, error) {
if s.Key == nil {
return nil, ErrNoKeyConfigured
}
if data == nil {
return nil, nil
}

aead, err := chacha20poly1305.NewX(s.Key)
if err != nil {
return nil, err
}

// Generate nonce
nonce := make([]byte, aead.NonceSize())
reader := s.randReader
if reader == nil {
reader = rand.Reader
}
if _, err := io.ReadFull(reader, nonce); err != nil {
return nil, err
}

// Pre-obfuscate the plaintext using nonce as entropy
// This ensures CPU encryption routines never see raw plaintext
obfuscated := data
if s.Obfuscator != nil {
obfuscated = s.Obfuscator.Obfuscate(data, nonce)
}

// Encrypt the obfuscated data
// Output: [nonce | ciphertext | auth tag]
ciphertext := aead.Seal(nonce, nonce, obfuscated, nil)

return ciphertext, nil
}

// Out decrypts the data and reverses obfuscation.
// The flow is: decrypt -> deobfuscate -> plaintext
func (s *ChaChaPolySigil) Out(data []byte) ([]byte, error) {
if s.Key == nil {
return nil, ErrNoKeyConfigured
}
if data == nil {
return nil, nil
}

aead, err := chacha20poly1305.NewX(s.Key)
if err != nil {
return nil, err
}

minLen := aead.NonceSize() + aead.Overhead()
if len(data) < minLen {
return nil, ErrCiphertextTooShort
}

// Extract nonce from ciphertext
nonce := data[:aead.NonceSize()]
ciphertext := data[aead.NonceSize():]

// Decrypt
obfuscated, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
return nil, ErrDecryptionFailed
}

// Deobfuscate using the same nonce as entropy
plaintext := obfuscated
if s.Obfuscator != nil {
plaintext = s.Obfuscator.Deobfuscate(obfuscated, nonce)
}

if len(plaintext) == 0 {
return []byte{}, nil
}

return plaintext, nil
}

// GetNonceFromCiphertext extracts the nonce from encrypted output.
// This is provided for debugging/logging purposes only.
// The nonce should NOT be stored separately in headers.
func GetNonceFromCiphertext(ciphertext []byte) ([]byte, error) {
nonceSize := chacha20poly1305.NonceSizeX
if len(ciphertext) < nonceSize {
return nil, ErrCiphertextTooShort
}
nonceCopy := make([]byte, nonceSize)
copy(nonceCopy, ciphertext[:nonceSize])
return nonceCopy, nil
}
Loading