Add new packages for cryptographic operations, session management, and I/O handling: - pkg/crypt/chachapoly: ChaCha20-Poly1305 AEAD encryption - pkg/crypt/lthn: Lethean-specific key derivation and encryption - pkg/crypt/rsa: RSA key generation, encryption, and signing - pkg/io/node: CryptoNote node I/O and protocol handling - pkg/io/sigil: Cryptographic sigil generation and verification - pkg/session: Session parsing, HTML rendering, search, and video - internal/cmd/forge: Forgejo auth status command - internal/cmd/session: Session management CLI command Also gitignore build artifacts (bugseti binary, i18n-validate). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
373 lines
10 KiB
Go
373 lines
10 KiB
Go
// This file implements the Pre-Obfuscation Layer Protocol with
|
|
// XChaCha20-Poly1305 encryption. The protocol applies a reversible transformation
|
|
// to plaintext BEFORE it reaches CPU encryption routines, providing defense-in-depth
|
|
// against side-channel attacks.
|
|
//
|
|
// The encryption flow is:
|
|
//
|
|
// plaintext -> obfuscate(nonce) -> encrypt -> [nonce || ciphertext || tag]
|
|
//
|
|
// The decryption flow is:
|
|
//
|
|
// [nonce || ciphertext || tag] -> decrypt -> deobfuscate(nonce) -> plaintext
|
|
package sigil
|
|
|
|
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("sigil: invalid key size, must be 32 bytes")
|
|
// ErrCiphertextTooShort is returned when the ciphertext is too short to decrypt.
|
|
ErrCiphertextTooShort = errors.New("sigil: ciphertext too short")
|
|
// ErrDecryptionFailed is returned when decryption or authentication fails.
|
|
ErrDecryptionFailed = errors.New("sigil: decryption failed")
|
|
// ErrNoKeyConfigured is returned when no encryption key has been set.
|
|
ErrNoKeyConfigured = errors.New("sigil: no encryption key configured")
|
|
)
|
|
|
|
// PreObfuscator applies a reversible transformation to data before encryption.
|
|
// This ensures that raw plaintext patterns are never sent directly to CPU
|
|
// encryption routines, providing defense against side-channel attacks.
|
|
//
|
|
// Implementations must be deterministic: given the same entropy, the transformation
|
|
// must be perfectly reversible: Deobfuscate(Obfuscate(x, e), e) == x
|
|
type PreObfuscator interface {
|
|
// Obfuscate transforms plaintext before encryption using the provided entropy.
|
|
// The entropy is typically the encryption nonce, ensuring the transformation
|
|
// is unique per-encryption without additional random generation.
|
|
Obfuscate(data []byte, entropy []byte) []byte
|
|
|
|
// Deobfuscate reverses the transformation after decryption.
|
|
// Must be called with the same entropy used during Obfuscate.
|
|
Deobfuscate(data []byte, entropy []byte) []byte
|
|
}
|
|
|
|
// XORObfuscator performs XOR-based obfuscation using an entropy-derived key stream.
|
|
//
|
|
// The key stream is generated using SHA-256 in counter mode:
|
|
//
|
|
// keyStream[i*32:(i+1)*32] = SHA256(entropy || BigEndian64(i))
|
|
//
|
|
// This provides a cryptographically uniform key stream that decorrelates
|
|
// plaintext patterns from the data seen by the encryption routine.
|
|
// XOR is symmetric, so obfuscation and deobfuscation use the same operation.
|
|
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 provides stronger obfuscation through byte shuffling and masking.
|
|
//
|
|
// The obfuscation process:
|
|
// 1. Generate a mask from entropy using SHA-256 in counter mode
|
|
// 2. XOR the data with the mask
|
|
// 3. Generate a deterministic permutation using Fisher-Yates shuffle
|
|
// 4. Reorder bytes according to the permutation
|
|
//
|
|
// This provides both value transformation (XOR mask) and position transformation
|
|
// (shuffle), making pattern analysis more difficult than XOR alone.
|
|
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
|
|
}
|