go-io/sigil/crypto_sigil.go
Snider 280c137607
Some checks are pending
CI / test (push) Waiting to run
CI / auto-fix (push) Waiting to run
CI / auto-merge (push) Waiting to run
fix(io/sigil): remove fixed-nonce path — AEAD nonce-uniqueness invariant restored (CRITICAL)
NewChaChaPolySigil(key, nonce any) now only accepts nil or
PreObfuscator. Passing []byte (or any other type) returns a typed
error: "fixed-nonce []byte path removed; use PreObfuscator or nil".

In() always reads a fresh random nonce per call. The stored fixed-
nonce field is removed from the struct.

Cerberus DREAD CRITICAL #1049 — ChaCha20-Poly1305 catastrophically
fails under nonce reuse: leaks XOR(plaintext_a, plaintext_b) and lets
an attacker recover the Poly1305 one-time key for unlimited
authenticated forgeries. Production callers (cube/cube.go x4,
workspace/service.go x1) all pass nil → safe. This closes the API
shape that invited future deterministic-ciphertext misuse.

Doc comment warns explicitly: nonce uniqueness is the invariant; if
deterministic AEAD is needed, that's a future PR backed by AES-GCM-SIV.

Tests:
- []byte non-empty / empty / typed-nil → all rejected
- string nonce → rejected
- Same-plaintext encryption produces DIFFERENT nonce prefixes AND
  DIFFERENT ciphertexts (regression catches any future fixed-nonce
  reintroduction)

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=1049
2026-04-25 19:05:08 +01:00

365 lines
9.7 KiB
Go

// Example: cipherSigil, _ := sigil.NewChaChaPolySigil([]byte("0123456789abcdef0123456789abcdef"), nil)
// Example: ciphertext, _ := cipherSigil.In([]byte("payload"))
// Example: plaintext, _ := cipherSigil.Out(ciphertext)
package sigil
import (
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
goio "io"
core "dappco.re/go/core"
"golang.org/x/crypto/chacha20poly1305"
)
var (
// Example: errors.Is(err, sigil.InvalidKeyError)
InvalidKeyError = core.E("sigil.InvalidKeyError", "invalid key size, must be 32 bytes", nil)
// Example: errors.Is(err, sigil.InvalidNonceError)
InvalidNonceError = core.E("sigil.InvalidNonceError", "invalid nonce argument; use PreObfuscator or nil", nil)
// Example: errors.Is(err, sigil.CiphertextTooShortError)
CiphertextTooShortError = core.E("sigil.CiphertextTooShortError", "ciphertext too short", nil)
// Example: errors.Is(err, sigil.DecryptionFailedError)
DecryptionFailedError = core.E("sigil.DecryptionFailedError", "decryption failed", nil)
// Example: errors.Is(err, sigil.NoKeyConfiguredError)
NoKeyConfiguredError = core.E("sigil.NoKeyConfiguredError", "no encryption key configured", nil)
)
// Example: obfuscator := &sigil.XORObfuscator{}
type PreObfuscator interface {
Obfuscate(data []byte, entropy []byte) []byte
Deobfuscate(data []byte, entropy []byte) []byte
}
// Example: obfuscator := &sigil.XORObfuscator{}
type XORObfuscator struct{}
func (obfuscator *XORObfuscator) Obfuscate(data []byte, entropy []byte) []byte {
if len(data) == 0 {
return data
}
return obfuscator.transform(data, entropy)
}
func (obfuscator *XORObfuscator) Deobfuscate(data []byte, entropy []byte) []byte {
if len(data) == 0 {
return data
}
return obfuscator.transform(data, entropy)
}
func (obfuscator *XORObfuscator) transform(data []byte, entropy []byte) []byte {
result := make([]byte, len(data))
keyStream := obfuscator.deriveKeyStream(entropy, len(data))
for i := range data {
result[i] = data[i] ^ keyStream[i]
}
return result
}
func (obfuscator *XORObfuscator) deriveKeyStream(entropy []byte, length int) []byte {
stream := make([]byte, length)
hashFunction := sha256.New()
blockNum := uint64(0)
offset := 0
for offset < length {
hashFunction.Reset()
hashFunction.Write(entropy)
var blockBytes [8]byte
binary.BigEndian.PutUint64(blockBytes[:], blockNum)
hashFunction.Write(blockBytes[:])
block := hashFunction.Sum(nil)
copyLen := min(len(block), length-offset)
copy(stream[offset:], block[:copyLen])
offset += copyLen
blockNum++
}
return stream
}
// Example: obfuscator := &sigil.ShuffleMaskObfuscator{}
type ShuffleMaskObfuscator struct{}
func (obfuscator *ShuffleMaskObfuscator) Obfuscate(data []byte, entropy []byte) []byte {
if len(data) == 0 {
return data
}
result := make([]byte, len(data))
copy(result, data)
permutation := obfuscator.generatePermutation(entropy, len(data))
mask := obfuscator.deriveMask(entropy, len(data))
for i := range result {
result[i] ^= mask[i]
}
shuffled := make([]byte, len(data))
for destinationIndex, sourceIndex := range permutation {
shuffled[destinationIndex] = result[sourceIndex]
}
return shuffled
}
func (obfuscator *ShuffleMaskObfuscator) Deobfuscate(data []byte, entropy []byte) []byte {
if len(data) == 0 {
return data
}
result := make([]byte, len(data))
permutation := obfuscator.generatePermutation(entropy, len(data))
mask := obfuscator.deriveMask(entropy, len(data))
for destinationIndex, sourceIndex := range permutation {
result[sourceIndex] = data[destinationIndex]
}
for i := range result {
result[i] ^= mask[i]
}
return result
}
func (obfuscator *ShuffleMaskObfuscator) generatePermutation(entropy []byte, length int) []int {
permutation := make([]int, length)
for i := range permutation {
permutation[i] = i
}
hashFunction := sha256.New()
hashFunction.Write(entropy)
hashFunction.Write([]byte("permutation"))
seed := hashFunction.Sum(nil)
for i := length - 1; i > 0; i-- {
hashFunction.Reset()
hashFunction.Write(seed)
var iBytes [8]byte
binary.BigEndian.PutUint64(iBytes[:], uint64(i))
hashFunction.Write(iBytes[:])
jBytes := hashFunction.Sum(nil)
j := int(binary.BigEndian.Uint64(jBytes[:8]) % uint64(i+1))
permutation[i], permutation[j] = permutation[j], permutation[i]
}
return permutation
}
func (obfuscator *ShuffleMaskObfuscator) deriveMask(entropy []byte, length int) []byte {
mask := make([]byte, length)
hashFunction := sha256.New()
blockNum := uint64(0)
offset := 0
for offset < length {
hashFunction.Reset()
hashFunction.Write(entropy)
hashFunction.Write([]byte("mask"))
var blockBytes [8]byte
binary.BigEndian.PutUint64(blockBytes[:], blockNum)
hashFunction.Write(blockBytes[:])
block := hashFunction.Sum(nil)
copyLen := min(len(block), length-offset)
copy(mask[offset:], block[:copyLen])
offset += copyLen
blockNum++
}
return mask
}
// Example: cipherSigil, _ := sigil.NewChaChaPolySigil(
// Example: []byte("0123456789abcdef0123456789abcdef"),
// Example: &sigil.ShuffleMaskObfuscator{},
// Example: )
type ChaChaPolySigil struct {
key []byte
nonceSize int
obfuscator PreObfuscator
randomReader goio.Reader
}
// Example: key := cipherSigil.Key()
func (s *ChaChaPolySigil) Key() []byte {
result := make([]byte, len(s.key))
copy(result, s.key)
return result
}
// Nonce returns nil. Encryption nonces are generated per message by In and
// prepended to the ciphertext.
func (s *ChaChaPolySigil) Nonce() []byte {
return nil
}
// Example: ob := cipherSigil.Obfuscator()
func (s *ChaChaPolySigil) Obfuscator() PreObfuscator {
return s.obfuscator
}
// Example: cipherSigil.SetObfuscator(nil)
func (s *ChaChaPolySigil) SetObfuscator(obfuscator PreObfuscator) {
s.obfuscator = obfuscator
}
// NewChaChaPolySigil creates a ChaCha20-Poly1305 sigil. The nonce argument is
// retained for API compatibility; pass nil for the default pre-obfuscator or a
// PreObfuscator for custom pre-obfuscation. Fixed []byte nonces are rejected:
// ChaCha20-Poly1305 catastrophically fails under nonce reuse, leaking plaintext
// relationships and enabling authenticated forgeries. In always generates a
// fresh random nonce and prepends it to the ciphertext.
//
// WARNING: when using a custom PreObfuscator, nonce uniqueness remains the
// caller's responsibility. The PreObfuscator must treat the supplied entropy as
// a per-message nonce and must not introduce deterministic nonce reuse.
func NewChaChaPolySigil(key []byte, nonce any) (*ChaChaPolySigil, error) {
if len(key) != 32 {
return nil, InvalidKeyError
}
keyCopy := make([]byte, 32)
copy(keyCopy, key)
sigil := &ChaChaPolySigil{
key: keyCopy,
nonceSize: chacha20poly1305.NonceSizeX,
randomReader: rand.Reader,
}
switch value := nonce.(type) {
case nil:
sigil.obfuscator = &XORObfuscator{}
case []byte:
return nil, core.E("sigil.NewChaChaPolySigil", "fixed-nonce []byte path removed; use PreObfuscator or nil", InvalidNonceError)
case PreObfuscator:
if value == nil {
sigil.obfuscator = &XORObfuscator{}
return sigil, nil
}
sigil.obfuscator = value
default:
return nil, core.E("sigil.NewChaChaPolySigil", "nonce must be PreObfuscator or nil", InvalidNonceError)
}
return sigil, nil
}
func (s *ChaChaPolySigil) In(data []byte) ([]byte, error) {
if s.key == nil {
return nil, NoKeyConfiguredError
}
if data == nil {
return nil, nil
}
aead, err := s.newAEAD()
if err != nil {
return nil, core.E("sigil.ChaChaPolySigil.In", "create cipher", err)
}
nonce := make([]byte, aead.NonceSize())
reader := s.randomReader
if reader == nil {
reader = rand.Reader
}
if _, err := goio.ReadFull(reader, nonce); err != nil {
return nil, core.E("sigil.ChaChaPolySigil.In", "read nonce", err)
}
obfuscated := data
if s.obfuscator != nil {
obfuscated = s.obfuscator.Obfuscate(data, cloneBytes(nonce))
}
ciphertext := aead.Seal(nonce, nonce, obfuscated, nil)
return ciphertext, nil
}
func (s *ChaChaPolySigil) Out(data []byte) ([]byte, error) {
if s.key == nil {
return nil, NoKeyConfiguredError
}
if data == nil {
return nil, nil
}
aead, err := s.newAEAD()
if err != nil {
return nil, core.E("sigil.ChaChaPolySigil.Out", "create cipher", err)
}
minLen := aead.NonceSize() + aead.Overhead()
if len(data) < minLen {
return nil, CiphertextTooShortError
}
nonce := data[:aead.NonceSize()]
ciphertext := data[aead.NonceSize():]
obfuscated, err := aead.Open(nil, nonce, ciphertext, nil)
if err != nil {
// The underlying aead error is intentionally hidden: surfacing raw AEAD errors can
// leak oracle information to an attacker. DecryptionFailedError is the safe sentinel.
return nil, core.E("sigil.ChaChaPolySigil.Out", "decrypt ciphertext", DecryptionFailedError)
}
plaintext := obfuscated
if s.obfuscator != nil {
plaintext = s.obfuscator.Deobfuscate(obfuscated, cloneBytes(nonce))
}
if len(plaintext) == 0 {
return []byte{}, nil
}
return plaintext, nil
}
func (s *ChaChaPolySigil) newAEAD() (cipher.AEAD, error) {
switch s.activeNonceSize() {
case chacha20poly1305.NonceSize:
return chacha20poly1305.New(s.key)
case chacha20poly1305.NonceSizeX:
return chacha20poly1305.NewX(s.key)
default:
return nil, InvalidNonceError
}
}
func (s *ChaChaPolySigil) activeNonceSize() int {
if s.nonceSize != 0 {
return s.nonceSize
}
return chacha20poly1305.NonceSizeX
}
func cloneBytes(data []byte) []byte {
result := make([]byte, len(data))
copy(result, data)
return result
}
// Example: nonce, _ := sigil.NonceFromCiphertext(ciphertext)
func NonceFromCiphertext(ciphertext []byte) ([]byte, error) {
nonceSize := chacha20poly1305.NonceSizeX
if len(ciphertext) < nonceSize {
return nil, CiphertextTooShortError
}
nonceCopy := make([]byte, nonceSize)
copy(nonceCopy, ciphertext[:nonceSize])
return nonceCopy, nil
}