gnarl_wire.go raw

   1  package crypto
   2  
   3  // Gnarl wire format for authenticated encrypted packets.
   4  //
   5  // Packet layout (64-byte header + variable ciphertext):
   6  //
   7  //	[10 bytes]  Nonce (counter-based, 80-bit)
   8  //	[27 bytes]  Identity (GnarlMid of sender spore fingerprint)
   9  //	[27 bytes]  Auth tag (GMid(per_packet_mac_key || nonce || identity || ciphertext_hash))
  10  //	[variable]  Ciphertext (ChaCha20, keystream starts at block 1)
  11  //
  12  // Total overhead: 64 bytes. Fits within one UDP MTU fragment with ~1400
  13  // bytes of payload.
  14  //
  15  // The auth tag binds the nonce, identity, and ciphertext together under
  16  // the shared secret (derived from ExchangeV2). An attacker without the
  17  // secret cannot forge the tag, reorder packets, or substitute identities.
  18  //
  19  // The identity field is the GnarlMid of the sender's spore fingerprint,
  20  // giving a 27-byte (216-bit, birthday ~108-bit) collision-resistant
  21  // identifier. This is NOT encrypted — it allows the receiver to look up
  22  // the shared secret for this sender before decrypting.
  23  
  24  import (
  25  	"crypto/sha256"
  26  	"encoding/binary"
  27  	"errors"
  28  
  29  	"golang.org/x/crypto/chacha20"
  30  )
  31  
  32  const (
  33  	// GnarlNonceLen is the nonce size in bytes (80 bits).
  34  	GnarlNonceLen = 10
  35  
  36  	// GnarlHeaderLen is the total header size: nonce + identity + auth tag.
  37  	GnarlHeaderLen = GnarlNonceLen + GnarlMidBytes + GnarlMidBytes // 10 + 27 + 27 = 64
  38  )
  39  
  40  // GnarlPacket is an authenticated encrypted packet in the Gnarl wire format.
  41  type GnarlPacket struct {
  42  	Nonce      [GnarlNonceLen]byte // 80-bit nonce (counter-based)
  43  	Identity   GnarlMid            // sender identity (GMid of spore fingerprint)
  44  	AuthTag    GnarlMid            // authentication tag
  45  	Ciphertext []byte              // CTR-encrypted payload
  46  }
  47  
  48  // GnarlSeal encrypts plaintext and produces an authenticated packet.
  49  //
  50  // Parameters:
  51  //   - secret: shared Hamadryad secret from key exchange
  52  //   - identity: sender's GnarlMid (public, used for session lookup)
  53  //   - nonce: 80-bit nonce (must be unique per packet for a given secret)
  54  //   - plaintext: data to encrypt
  55  //
  56  // The ciphertext is produced by ChaCha20 keyed with the shared secret.
  57  // The auth tag uses a per-packet MAC key derived from ChaCha20 block 0,
  58  // eliminating SWIFFT homomorphic relations between tags across packets.
  59  func GnarlSeal(secret Hamadryad, identity GnarlMid, nonce [GnarlNonceLen]byte, plaintext []byte) *GnarlPacket {
  60  	macKey, cipher := gnarlCipherAndMAC(secret, nonce)
  61  
  62  	// Encrypt: ChaCha20 keystream (starts at block 1, after MAC key block).
  63  	ciphertext := make([]byte, len(plaintext))
  64  	cipher.XORKeyStream(ciphertext, plaintext)
  65  
  66  	// Auth tag with per-packet MAC key.
  67  	authTag := gnarlAuthTag(macKey, nonce, identity, ciphertext)
  68  
  69  	return &GnarlPacket{
  70  		Nonce:      nonce,
  71  		Identity:   identity,
  72  		AuthTag:    authTag,
  73  		Ciphertext: ciphertext,
  74  	}
  75  }
  76  
  77  // GnarlOpen verifies and decrypts an authenticated packet.
  78  //
  79  // Returns the plaintext if the auth tag is valid, or an error if
  80  // verification fails (tampered, wrong key, replayed, etc.).
  81  func GnarlOpen(secret Hamadryad, pkt *GnarlPacket) ([]byte, error) {
  82  	if pkt == nil {
  83  		return nil, errors.New("gnarl: nil packet")
  84  	}
  85  
  86  	macKey, cipher := gnarlCipherAndMAC(secret, pkt.Nonce)
  87  
  88  	// Verify auth tag with per-packet MAC key.
  89  	expected := gnarlAuthTag(macKey, pkt.Nonce, pkt.Identity, pkt.Ciphertext)
  90  	if expected != pkt.AuthTag {
  91  		return nil, errors.New("gnarl: authentication failed")
  92  	}
  93  
  94  	// Decrypt (cipher is already positioned at block 1).
  95  	plaintext := make([]byte, len(pkt.Ciphertext))
  96  	cipher.XORKeyStream(plaintext, pkt.Ciphertext)
  97  	return plaintext, nil
  98  }
  99  
 100  // MarshalGnarlPacket serializes a packet to wire format.
 101  func MarshalGnarlPacket(pkt *GnarlPacket) []byte {
 102  	buf := make([]byte, GnarlHeaderLen+len(pkt.Ciphertext))
 103  	copy(buf[0:GnarlNonceLen], pkt.Nonce[:])
 104  	copy(buf[GnarlNonceLen:GnarlNonceLen+GnarlMidBytes], pkt.Identity[:])
 105  	copy(buf[GnarlNonceLen+GnarlMidBytes:GnarlHeaderLen], pkt.AuthTag[:])
 106  	copy(buf[GnarlHeaderLen:], pkt.Ciphertext)
 107  	return buf
 108  }
 109  
 110  // UnmarshalGnarlPacket deserializes a packet from wire format.
 111  func UnmarshalGnarlPacket(data []byte) (*GnarlPacket, error) {
 112  	if len(data) < GnarlHeaderLen {
 113  		return nil, errors.New("gnarl: packet too short")
 114  	}
 115  
 116  	pkt := &GnarlPacket{}
 117  	copy(pkt.Nonce[:], data[0:GnarlNonceLen])
 118  	copy(pkt.Identity[:], data[GnarlNonceLen:GnarlNonceLen+GnarlMidBytes])
 119  	copy(pkt.AuthTag[:], data[GnarlNonceLen+GnarlMidBytes:GnarlHeaderLen])
 120  	pkt.Ciphertext = make([]byte, len(data)-GnarlHeaderLen)
 121  	copy(pkt.Ciphertext, data[GnarlHeaderLen:])
 122  	return pkt, nil
 123  }
 124  
 125  // gnarlCipherAndMAC derives a per-packet MAC key and a ChaCha20 cipher
 126  // positioned for encryption, following the RFC 8439 pattern:
 127  //   - Block 0: consumed to derive a one-time MAC key (56 bytes)
 128  //   - Block 1+: available for encryption keystream
 129  //
 130  // The ChaCha20 key is derived from the shared secret via a single
 131  // Hamadryad hash evaluation (one-time key derivation, not a PRF).
 132  func gnarlCipherAndMAC(secret Hamadryad, nonce [GnarlNonceLen]byte) (Hamadryad, *chacha20.Cipher) {
 133  	// Derive 32-byte ChaCha20 key from shared secret.
 134  	keyHash := Hash(append([]byte("gnarl-chacha20-key"), secret[:]...))
 135  	var key [32]byte
 136  	copy(key[:], keyHash[:32])
 137  
 138  	// Zero-pad 10-byte wire nonce to 12 bytes.
 139  	var chachaNonce [chacha20.NonceSize]byte
 140  	copy(chachaNonce[:GnarlNonceLen], nonce[:])
 141  
 142  	cipher, _ := chacha20.NewUnauthenticatedCipher(key[:], chachaNonce[:])
 143  
 144  	// Derive per-packet MAC key from block 0 (first 64 bytes of keystream).
 145  	// Use first 56 bytes (Hamadryad-sized) as the one-time MAC key.
 146  	var macBlock [64]byte
 147  	cipher.XORKeyStream(macBlock[:], macBlock[:])
 148  	var macKey Hamadryad
 149  	copy(macKey[:], macBlock[:HamBytes])
 150  
 151  	// Cipher is now positioned at block 1, ready for encryption.
 152  	return macKey, cipher
 153  }
 154  
 155  // gnarlAuthTag computes the authentication tag for a packet.
 156  // tag = GMid(macKey || nonce || identity || GHash(ciphertext))
 157  //
 158  // The macKey is a per-packet one-time key derived from ChaCha20 block 0,
 159  // so each packet's tag uses an independent pseudorandom key. This
 160  // eliminates homomorphic relations between tags across packets.
 161  func gnarlAuthTag(macKey Hamadryad, nonce [GnarlNonceLen]byte, identity GnarlMid, ciphertext []byte) GnarlMid {
 162  	// Hash the ciphertext with GHash for a fixed-size binding.
 163  	ctHash := GHash(ciphertext)
 164  
 165  	var tagInput []byte
 166  	tagInput = append(tagInput, macKey[:]...)
 167  	tagInput = append(tagInput, nonce[:]...)
 168  	tagInput = append(tagInput, identity[:]...)
 169  	tagInput = append(tagInput, ctHash[:]...)
 170  	return GMid(tagInput)
 171  }
 172  
 173  // GnarlSchnorrChallenge computes the Fiat-Shamir challenge for Gnarl
 174  // Schnorr signatures using SHA-256 truncated to 27 bytes.
 175  //
 176  // SHA-256 is used instead of GMid because the Schnorr security proof
 177  // requires random oracle behaviour. SWIFFT's additive homomorphism
 178  // makes it distinguishable from a random oracle, which would be
 179  // exploitable in multi-signature protocols. SHA-256 has no known
 180  // algebraic structure and is the standard choice for Fiat-Shamir.
 181  //
 182  // The output is truncated to 27 bytes (216 bits) to match the Gnarl
 183  // scalar field size. Collision resistance: 2^108 (birthday bound on
 184  // 216-bit output), which exceeds the ~107-bit Pollard-rho security
 185  // of the Gnarl torus discrete log.
 186  func GnarlSchnorrChallenge(data []byte) [GnarlMidBytes]byte {
 187  	h := sha256.Sum256(data)
 188  	var result [GnarlMidBytes]byte
 189  	copy(result[:], h[:GnarlMidBytes])
 190  	return result
 191  }
 192  
 193  // GnarlNonceFromCounter creates a nonce from a 64-bit counter and 16-bit epoch.
 194  // This covers the common case of counter-based nonces with epoch rotation.
 195  func GnarlNonceFromCounter(epoch uint16, counter uint64) [GnarlNonceLen]byte {
 196  	var nonce [GnarlNonceLen]byte
 197  	binary.LittleEndian.PutUint64(nonce[0:8], counter)
 198  	binary.LittleEndian.PutUint16(nonce[8:10], epoch)
 199  	return nonce
 200  }
 201