-
-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add encryption sigil with pre-obfuscation layer #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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] | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current implementation of
generatePermutationre-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 indexjcan 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:
I also recommend refactoring the stream generation logic from
XORObfuscator.deriveKeyStreamandShuffleMaskObfuscator.deriveMaskinto a shared helper function to promote code reuse.