Skip to content

Commit 1e1dfee

Browse files
authored
Merge pull request #41 from Snider/claude/test-sigil-encryption-wdRbW
feat: add encryption sigil with pre-obfuscation layer
2 parents e8a3fb3 + afb1166 commit 1e1dfee

File tree

4 files changed

+1489
-0
lines changed

4 files changed

+1489
-0
lines changed

pkg/enchantrix/crypto_sigil.go

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,338 @@
1+
package enchantrix
2+
3+
import (
4+
"crypto/rand"
5+
"crypto/sha256"
6+
"encoding/binary"
7+
"errors"
8+
"io"
9+
10+
"golang.org/x/crypto/chacha20poly1305"
11+
)
12+
13+
var (
14+
// ErrInvalidKey is returned when the encryption key is invalid.
15+
ErrInvalidKey = errors.New("enchantrix: invalid key size, must be 32 bytes")
16+
// ErrCiphertextTooShort is returned when the ciphertext is too short to decrypt.
17+
ErrCiphertextTooShort = errors.New("enchantrix: ciphertext too short")
18+
// ErrDecryptionFailed is returned when decryption or authentication fails.
19+
ErrDecryptionFailed = errors.New("enchantrix: decryption failed")
20+
// ErrNoKeyConfigured is returned when no encryption key has been set.
21+
ErrNoKeyConfigured = errors.New("enchantrix: no encryption key configured")
22+
)
23+
24+
// PreObfuscator applies a reversible transformation to data before encryption.
25+
// This ensures that raw plaintext is never sent directly to CPU encryption routines.
26+
type PreObfuscator interface {
27+
// Obfuscate transforms plaintext before encryption.
28+
Obfuscate(data []byte, entropy []byte) []byte
29+
// Deobfuscate reverses the transformation after decryption.
30+
Deobfuscate(data []byte, entropy []byte) []byte
31+
}
32+
33+
// XORObfuscator performs XOR-based obfuscation using entropy-derived key stream.
34+
// This is a reversible transformation that ensures no cleartext patterns remain.
35+
type XORObfuscator struct{}
36+
37+
// Obfuscate XORs the data with a key stream derived from the entropy.
38+
func (x *XORObfuscator) Obfuscate(data []byte, entropy []byte) []byte {
39+
if len(data) == 0 {
40+
return data
41+
}
42+
return x.transform(data, entropy)
43+
}
44+
45+
// Deobfuscate reverses the XOR transformation (XOR is symmetric).
46+
func (x *XORObfuscator) Deobfuscate(data []byte, entropy []byte) []byte {
47+
if len(data) == 0 {
48+
return data
49+
}
50+
return x.transform(data, entropy)
51+
}
52+
53+
// transform applies XOR with an entropy-derived key stream.
54+
func (x *XORObfuscator) transform(data []byte, entropy []byte) []byte {
55+
result := make([]byte, len(data))
56+
keyStream := x.deriveKeyStream(entropy, len(data))
57+
for i := range data {
58+
result[i] = data[i] ^ keyStream[i]
59+
}
60+
return result
61+
}
62+
63+
// deriveKeyStream creates a deterministic key stream from entropy.
64+
func (x *XORObfuscator) deriveKeyStream(entropy []byte, length int) []byte {
65+
stream := make([]byte, length)
66+
h := sha256.New()
67+
68+
// Generate key stream in 32-byte blocks
69+
blockNum := uint64(0)
70+
offset := 0
71+
for offset < length {
72+
h.Reset()
73+
h.Write(entropy)
74+
var blockBytes [8]byte
75+
binary.BigEndian.PutUint64(blockBytes[:], blockNum)
76+
h.Write(blockBytes[:])
77+
block := h.Sum(nil)
78+
79+
copyLen := len(block)
80+
if offset+copyLen > length {
81+
copyLen = length - offset
82+
}
83+
copy(stream[offset:], block[:copyLen])
84+
offset += copyLen
85+
blockNum++
86+
}
87+
return stream
88+
}
89+
90+
// ShuffleMaskObfuscator applies byte-level shuffling based on entropy.
91+
// This provides additional diffusion before encryption.
92+
type ShuffleMaskObfuscator struct{}
93+
94+
// Obfuscate shuffles bytes and applies a mask derived from entropy.
95+
func (s *ShuffleMaskObfuscator) Obfuscate(data []byte, entropy []byte) []byte {
96+
if len(data) == 0 {
97+
return data
98+
}
99+
100+
result := make([]byte, len(data))
101+
copy(result, data)
102+
103+
// Generate permutation and mask from entropy
104+
perm := s.generatePermutation(entropy, len(data))
105+
mask := s.deriveMask(entropy, len(data))
106+
107+
// Apply mask first, then shuffle
108+
for i := range result {
109+
result[i] ^= mask[i]
110+
}
111+
112+
// Shuffle using Fisher-Yates with deterministic seed
113+
shuffled := make([]byte, len(data))
114+
for i, p := range perm {
115+
shuffled[i] = result[p]
116+
}
117+
118+
return shuffled
119+
}
120+
121+
// Deobfuscate reverses the shuffle and mask operations.
122+
func (s *ShuffleMaskObfuscator) Deobfuscate(data []byte, entropy []byte) []byte {
123+
if len(data) == 0 {
124+
return data
125+
}
126+
127+
result := make([]byte, len(data))
128+
129+
// Generate permutation and mask from entropy
130+
perm := s.generatePermutation(entropy, len(data))
131+
mask := s.deriveMask(entropy, len(data))
132+
133+
// Unshuffle first
134+
for i, p := range perm {
135+
result[p] = data[i]
136+
}
137+
138+
// Remove mask
139+
for i := range result {
140+
result[i] ^= mask[i]
141+
}
142+
143+
return result
144+
}
145+
146+
// generatePermutation creates a deterministic permutation from entropy.
147+
func (s *ShuffleMaskObfuscator) generatePermutation(entropy []byte, length int) []int {
148+
perm := make([]int, length)
149+
for i := range perm {
150+
perm[i] = i
151+
}
152+
153+
// Use entropy to seed a deterministic shuffle
154+
h := sha256.New()
155+
h.Write(entropy)
156+
h.Write([]byte("permutation"))
157+
seed := h.Sum(nil)
158+
159+
// Fisher-Yates shuffle with deterministic randomness
160+
for i := length - 1; i > 0; i-- {
161+
h.Reset()
162+
h.Write(seed)
163+
var iBytes [8]byte
164+
binary.BigEndian.PutUint64(iBytes[:], uint64(i))
165+
h.Write(iBytes[:])
166+
jBytes := h.Sum(nil)
167+
j := int(binary.BigEndian.Uint64(jBytes[:8]) % uint64(i+1))
168+
perm[i], perm[j] = perm[j], perm[i]
169+
}
170+
171+
return perm
172+
}
173+
174+
// deriveMask creates a mask byte array from entropy.
175+
func (s *ShuffleMaskObfuscator) deriveMask(entropy []byte, length int) []byte {
176+
mask := make([]byte, length)
177+
h := sha256.New()
178+
179+
blockNum := uint64(0)
180+
offset := 0
181+
for offset < length {
182+
h.Reset()
183+
h.Write(entropy)
184+
h.Write([]byte("mask"))
185+
var blockBytes [8]byte
186+
binary.BigEndian.PutUint64(blockBytes[:], blockNum)
187+
h.Write(blockBytes[:])
188+
block := h.Sum(nil)
189+
190+
copyLen := len(block)
191+
if offset+copyLen > length {
192+
copyLen = length - offset
193+
}
194+
copy(mask[offset:], block[:copyLen])
195+
offset += copyLen
196+
blockNum++
197+
}
198+
return mask
199+
}
200+
201+
// ChaChaPolySigil is a Sigil that encrypts/decrypts data using ChaCha20-Poly1305.
202+
// It applies pre-obfuscation before encryption to ensure raw plaintext never
203+
// goes directly to CPU encryption routines.
204+
//
205+
// The output format is:
206+
// [24-byte nonce][encrypted(obfuscated(plaintext))]
207+
//
208+
// Unlike demo implementations, the nonce is ONLY embedded in the ciphertext,
209+
// not exposed separately in headers.
210+
type ChaChaPolySigil struct {
211+
Key []byte
212+
Obfuscator PreObfuscator
213+
randReader io.Reader // for testing injection
214+
}
215+
216+
// NewChaChaPolySigil creates a new encryption sigil with the given key.
217+
// The key must be exactly 32 bytes.
218+
func NewChaChaPolySigil(key []byte) (*ChaChaPolySigil, error) {
219+
if len(key) != 32 {
220+
return nil, ErrInvalidKey
221+
}
222+
223+
keyCopy := make([]byte, 32)
224+
copy(keyCopy, key)
225+
226+
return &ChaChaPolySigil{
227+
Key: keyCopy,
228+
Obfuscator: &XORObfuscator{},
229+
randReader: rand.Reader,
230+
}, nil
231+
}
232+
233+
// NewChaChaPolySigilWithObfuscator creates a new encryption sigil with custom obfuscator.
234+
func NewChaChaPolySigilWithObfuscator(key []byte, obfuscator PreObfuscator) (*ChaChaPolySigil, error) {
235+
sigil, err := NewChaChaPolySigil(key)
236+
if err != nil {
237+
return nil, err
238+
}
239+
if obfuscator != nil {
240+
sigil.Obfuscator = obfuscator
241+
}
242+
return sigil, nil
243+
}
244+
245+
// In encrypts the data with pre-obfuscation.
246+
// The flow is: plaintext -> obfuscate -> encrypt
247+
func (s *ChaChaPolySigil) In(data []byte) ([]byte, error) {
248+
if s.Key == nil {
249+
return nil, ErrNoKeyConfigured
250+
}
251+
if data == nil {
252+
return nil, nil
253+
}
254+
255+
aead, err := chacha20poly1305.NewX(s.Key)
256+
if err != nil {
257+
return nil, err
258+
}
259+
260+
// Generate nonce
261+
nonce := make([]byte, aead.NonceSize())
262+
reader := s.randReader
263+
if reader == nil {
264+
reader = rand.Reader
265+
}
266+
if _, err := io.ReadFull(reader, nonce); err != nil {
267+
return nil, err
268+
}
269+
270+
// Pre-obfuscate the plaintext using nonce as entropy
271+
// This ensures CPU encryption routines never see raw plaintext
272+
obfuscated := data
273+
if s.Obfuscator != nil {
274+
obfuscated = s.Obfuscator.Obfuscate(data, nonce)
275+
}
276+
277+
// Encrypt the obfuscated data
278+
// Output: [nonce | ciphertext | auth tag]
279+
ciphertext := aead.Seal(nonce, nonce, obfuscated, nil)
280+
281+
return ciphertext, nil
282+
}
283+
284+
// Out decrypts the data and reverses obfuscation.
285+
// The flow is: decrypt -> deobfuscate -> plaintext
286+
func (s *ChaChaPolySigil) Out(data []byte) ([]byte, error) {
287+
if s.Key == nil {
288+
return nil, ErrNoKeyConfigured
289+
}
290+
if data == nil {
291+
return nil, nil
292+
}
293+
294+
aead, err := chacha20poly1305.NewX(s.Key)
295+
if err != nil {
296+
return nil, err
297+
}
298+
299+
minLen := aead.NonceSize() + aead.Overhead()
300+
if len(data) < minLen {
301+
return nil, ErrCiphertextTooShort
302+
}
303+
304+
// Extract nonce from ciphertext
305+
nonce := data[:aead.NonceSize()]
306+
ciphertext := data[aead.NonceSize():]
307+
308+
// Decrypt
309+
obfuscated, err := aead.Open(nil, nonce, ciphertext, nil)
310+
if err != nil {
311+
return nil, ErrDecryptionFailed
312+
}
313+
314+
// Deobfuscate using the same nonce as entropy
315+
plaintext := obfuscated
316+
if s.Obfuscator != nil {
317+
plaintext = s.Obfuscator.Deobfuscate(obfuscated, nonce)
318+
}
319+
320+
if len(plaintext) == 0 {
321+
return []byte{}, nil
322+
}
323+
324+
return plaintext, nil
325+
}
326+
327+
// GetNonceFromCiphertext extracts the nonce from encrypted output.
328+
// This is provided for debugging/logging purposes only.
329+
// The nonce should NOT be stored separately in headers.
330+
func GetNonceFromCiphertext(ciphertext []byte) ([]byte, error) {
331+
nonceSize := chacha20poly1305.NonceSizeX
332+
if len(ciphertext) < nonceSize {
333+
return nil, ErrCiphertextTooShort
334+
}
335+
nonceCopy := make([]byte, nonceSize)
336+
copy(nonceCopy, ciphertext[:nonceSize])
337+
return nonceCopy, nil
338+
}

0 commit comments

Comments
 (0)