cli/pkg/io/sigil/crypto_sigil.go
Claude 95261a92ff feat: add crypto, session, sigil, and node packages
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>
2026-02-08 20:52:28 +00:00

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
}