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