shadow_encrypt.go raw
1 package crypto
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/binary"
7 "errors"
8 "fmt"
9
10 "golang.org/x/crypto/chacha20"
11
12 "git.mleku.dev/mleku/dendrite/pkg/axiom"
13 "git.mleku.dev/mleku/dendrite/pkg/dissolve"
14 "git.mleku.dev/mleku/dendrite/pkg/epoch"
15 "git.mleku.dev/mleku/dendrite/pkg/grow"
16 "git.mleku.dev/mleku/dendrite/pkg/state"
17 )
18
19 // ShadowEncrypt encrypts plaintext using the binary/decimal shadow channel.
20 //
21 // The plaintext is converted to hexagram tokens, each independently
22 // permuted by an S_3 pair derived from the epoch Phase() at that token's
23 // position. The permuted tokens are then crystallized into a lattice
24 // nucleated from the recipient's public key.
25 //
26 // The encryption is structurally dependent on the binary/decimal
27 // incommensurability: the inner trigram rotates by the binary clock
28 // component of the phase, and the outer trigram rotates by the decimal
29 // clock component. Neither projection alone recovers the original.
30 //
31 // Algorithm:
32 // 1. Convert plaintext to hexagram tokens (state.EncodeBytes)
33 // 2. For each token, compute phase-derived S_3 pair and apply
34 // 3. Bond shadow elements into recipient's lattice
35 // 4. Run dissolution passes (add noise)
36 // 5. XOR bonding data with phase-derived keystream
37 // 6. Extract ciphertext
38 func ShadowEncrypt(
39 pubkey *PublicKey,
40 plaintext []byte,
41 params Params,
42 ep epoch.Epoch,
43 nonce []byte,
44 ) (*ShadowCiphertext, error) {
45 if !params.Valid() {
46 return nil, errors.New("crypto: invalid parameters")
47 }
48 if pubkey == nil || pubkey.Spore == nil {
49 return nil, errors.New("crypto: public key has no spore")
50 }
51 if len(nonce) == 0 {
52 return nil, errors.New("crypto: empty nonce")
53 }
54
55 // 1. Hexagram encoding: 3 bytes → 4 tokens (6-bit each).
56 tokens := state.EncodeBytes(plaintext)
57 if len(tokens) == 0 && len(plaintext) > 0 {
58 return nil, errors.New("crypto: hexagram encoding failed")
59 }
60
61 // 2. Phase-dependent shadow decomposition.
62 encrypted := make([]state.Hexagram, len(tokens))
63 for i, tok := range tokens {
64 binP, decP := PhasePerms(ep, i)
65 encrypted[i] = ShadowDecompose(tok, binP, decP)
66 }
67
68 // 3. Build shadow elements for lattice bonding.
69 factory := publicConstraintFactory(pubkey.Basis)
70 l := pubkey.Spore.Nucleate(params.N, factory)
71 if l.Size() == 0 {
72 return nil, errors.New("crypto: nucleation produced empty lattice")
73 }
74
75 tags := pubkey.Basis.Tags
76 if len(tags) == 0 {
77 return nil, errors.New("crypto: basis has no type tags")
78 }
79
80 solution := make(chan axiom.Element, len(encrypted))
81 for i, enc := range encrypted {
82 vertex, key := PhaseProjection(ep, i)
83 binP, _ := PhasePerms(ep, i)
84 solution <- ShadowElement{
85 Index: i,
86 Encrypted: enc,
87 ProjVertex: vertex,
88 ProjKey: key,
89 ProjPath: uint16(i),
90 PermIdx: uint8(binP),
91 TypeTag: tags[i%len(tags)],
92 }
93 }
94 close(solution)
95
96 // 4. Brownian walk accretion.
97 events := make(chan grow.Event, len(encrypted)*2)
98 ctx := context.Background()
99 grow.Run(ctx, l, solution, grow.Config{
100 MaxSteps: params.MaxWalkSteps,
101 Workers: 4,
102 }, events)
103 close(events)
104 for range events {
105 }
106
107 // 5. Dissolution passes — add noise.
108 var allNoise []NoiseSample
109 for range params.DissolutionPasses {
110 dissolved := make(chan axiom.Element, l.Size())
111 dissEvents := make(chan dissolve.Event, l.Size())
112 dissolve.ScanOnce(l, dissolve.Config{
113 Threshold: params.SmoothingParam,
114 }, dissolved, dissEvents)
115 close(dissolved)
116 close(dissEvents)
117 for range dissolved {
118 }
119 for ev := range dissEvents {
120 tag := ""
121 if ev.Element != nil {
122 tag = ev.Element.Type()
123 }
124 allNoise = append(allNoise, NoiseSample{
125 Index: uint64(ev.NodeID),
126 TypeTag: tag,
127 LockIn: ev.LockIn,
128 })
129 }
130 }
131
132 // 6. Extract bonding pattern and apply keystream masking.
133 sites := snapshot(l)
134
135 // Generate per-message nonce hash.
136 var nonceBuf []byte
137 nonceBuf = append(nonceBuf, []byte("shadow-keystream-v1")...)
138 nonceBuf = append(nonceBuf, []byte(pubkey.SporeHash)...)
139 nonceBuf = append(nonceBuf, []byte(ep.String())...)
140 nonceBuf = append(nonceBuf, nonce...)
141 nonceHash := Hash(nonceBuf)
142
143 // XOR value hashes with keystream for additional masking.
144 // The keystream is keyed by the site's lattice node ID, not its
145 // position in the slice, so the masking survives reordering.
146 for i := range sites {
147 if sites[i].Occupied {
148 block := keystreamForSite(nonceHash, sites[i].Index)
149 sites[i].ValueHash = xorHamadryad(sites[i].ValueHash, block)
150 }
151 }
152
153 return &ShadowCiphertext{
154 Sites: sites,
155 Noise: allNoise,
156 Params: params,
157 Basis: pubkey.Basis,
158 Nonce: nonceHash,
159 EpochDec: ep.DecExp,
160 EpochBin: ep.BinExp,
161 TokenCount: len(tokens),
162 OrigLen: len(plaintext),
163 }, nil
164 }
165
166 // ShadowDecrypt recovers plaintext from a shadow ciphertext using the
167 // private key and the matching epoch.
168 //
169 // Algorithm:
170 // 1. Unmask the bonding pattern using the nonce keystream
171 // 2. Read bonded values via private key (CVP trapdoor)
172 // 3. Recompute phase schedule from epoch
173 // 4. Apply inverse S_3 pairs to recover original hexagrams
174 // 5. Decode hexagrams to bytes
175 func ShadowDecrypt(
176 privkey *PrivateKey,
177 ct *ShadowCiphertext,
178 ep epoch.Epoch,
179 ) ([]byte, error) {
180 if ct == nil {
181 return nil, errors.New("crypto: nil shadow ciphertext")
182 }
183 if privkey == nil || privkey.ConstraintFactory == nil {
184 return nil, errors.New("crypto: private key has no constraint factory")
185 }
186
187 // 1. Unmask value hashes.
188 // The keystream is keyed by lattice node ID, matching the encrypt side.
189 sites := make([]SiteMark, len(ct.Sites))
190 copy(sites, ct.Sites)
191 for i := range sites {
192 if sites[i].Occupied {
193 block := keystreamForSite(ct.Nonce, sites[i].Index)
194 sites[i].ValueHash = xorHamadryad(sites[i].ValueHash, block)
195 }
196 }
197
198 // 2. Recover encrypted hexagram values from the bonding pattern.
199 // The private key holder can verify each site's occupant by
200 // testing all 64 hexagram values against the value hash.
201 type indexedHex struct {
202 index int
203 hex state.Hexagram
204 }
205 var recovered []indexedHex
206
207 for _, site := range sites {
208 if !site.Occupied {
209 continue
210 }
211 if site.LockIn.Less(ct.Params.SmoothingParam) {
212 continue // below noise floor
213 }
214
215 // Test all 64 hexagram values against the stored hash.
216 for h := range 64 {
217 candidate := hashValue(state.Hexagram(h))
218 if candidate == site.ValueHash {
219 // Use ProjPath as the token stream index.
220 // ShadowEncrypt stores the token index as ProjectionPath
221 // in each ShadowElement.
222 recovered = append(recovered, indexedHex{
223 index: int(site.ProjPath),
224 hex: state.Hexagram(h),
225 })
226 break
227 }
228 }
229 }
230
231 if len(recovered) == 0 {
232 return nil, errors.New("crypto: no shadow tokens recovered")
233 }
234
235 // 3. Reconstruct token stream, applying inverse phase permutations.
236 tokens := make([]state.Hexagram, ct.TokenCount)
237 for _, r := range recovered {
238 tokenIdx := r.index
239 if tokenIdx >= ct.TokenCount {
240 continue
241 }
242
243 // Apply inverse phase permutations.
244 binP, decP := PhasePerms(ep, tokenIdx)
245 tokens[tokenIdx] = ShadowRecompose(r.hex, binP, decP)
246 }
247
248 // 4. Decode hexagrams back to bytes.
249 return state.DecodeHexagrams(tokens, ct.OrigLen), nil
250 }
251
252 // keystreamForSite generates a Hamadryad-sized keystream block for a
253 // specific lattice site using ChaCha20.
254 //
255 // The ChaCha20 key is derived from the nonce via a single Hamadryad hash
256 // (one-time key derivation, not used as a PRF). The site index selects
257 // the ChaCha20 block counter, giving each site an independent keystream
258 // block without relying on SWIFFT's unproven PRF properties.
259 func keystreamForSite(nonce Hamadryad, siteIndex uint64) Hamadryad {
260 // Derive ChaCha20 key from nonce (one-time hash, proven secure).
261 keyHash := Hash(append([]byte("shadow-chacha20-key"), nonce[:]...))
262 var key [chacha20.KeySize]byte
263 copy(key[:], keyHash[:chacha20.KeySize])
264
265 // Derive ChaCha20 nonce from site index.
266 var chachaNonce [chacha20.NonceSize]byte
267 binary.LittleEndian.PutUint64(chachaNonce[:8], siteIndex)
268
269 cipher, _ := chacha20.NewUnauthenticatedCipher(key[:], chachaNonce[:])
270 var result Hamadryad
271 cipher.XORKeyStream(result[:], result[:]) // XOR zeros = raw keystream
272 return result
273 }
274
275 // xorHamadryad XORs two Hamadryad values byte-by-byte.
276 func xorHamadryad(a, b Hamadryad) Hamadryad {
277 var result Hamadryad
278 for i := range HamBytes {
279 result[i] = a[i] ^ b[i]
280 }
281 return result
282 }
283
284 // generateNonce creates a cryptographically random nonce.
285 func generateNonce() []byte {
286 nonce := make([]byte, 32)
287 if _, err := rand.Read(nonce); err != nil {
288 panic(fmt.Sprintf("crypto/rand: %v", err))
289 }
290 return nonce
291 }
292