From 1dd66766a7fe10f7ba784ea93c3ff56c3b77927b Mon Sep 17 00:00:00 2001 From: Youssef El Housni Date: Wed, 30 Sep 2026 18:36:36 -0400 Subject: [PATCH] fix(mamabear): derive the Poseidon2 round numbers for this field The mamabear Poseidon2 parameters were inherited from koalabear: 6 full rounds and 21 partial rounds. Only the diagonal and the S-box degree carry over safely. The round numbers do not. Eq. (1) of the Poseidon2 paper (https://eprint.iacr.org/2023/323.pdf) bounds the partial rounds by R_P >= ceil(1.075 * max(R_interp, R_GB)) R_interp = ceil(min{kappa, n}/log2(d)) + ceil(log_d(t)) - 5 with n = ceil(log2(p)). The bound grows with min{kappa, log2(p)}, so a wider field needs MORE rounds, not fewer. koalabear saturates at n = 31 and needs 20; mamabear at n = 49 needs 32, with R_interp = 29 binding. The inherited 21 satisfies Eq. (1) only up to roughly kappa = 33. Set R_F = 8 and R_P = 32 for both the width-16 compression and width-24 sponge parameters. R_F = 8 is the paper's value throughout, and is also the +2 margin over the statistical minimum of 6 that the inherited value had dropped. The S-box degree is unchanged and was already right: d = 3 is the smallest d >= 3 with gcd(d, p-1) = 1, since p-1 = 2^34 * 7 * 31 * 151. The round constants are derived from the round counts by the Grain-style LFSR in initRC, so they regenerate with the new schedule; the known-answer vectors are recomputed accordingly. Those vectors come from this implementation, as the existing ones did, so they guard against regression rather than validating against an independent source. The KAT now builds its permutation from the generated parameters instead of hardcoding the counts, so it cannot drift from the shipped configuration again. The derivation was validated before being applied: the same formula reproduces all six instances of Table 1 of the paper, and all six of Plonky3's shipped constants for babybear (13/21/30 at t = 16/24/32) and koalabear (20/23/31). Only mamabear changes here. Regenerating leaves babybear, koalabear and goldilocks byte for byte unchanged. Note that koalabear ships R_F = 6 against Plonky3's 8, and goldilocks 6/17 against a derived 8/22; both are left alone and are worth a separate look. Co-Authored-By: Claude Opus 5 (1M context) --- field/mamabear/poseidon2/hash.go | 10 +- field/mamabear/poseidon2/poseidon2_test.go | 130 +++++++++++------- .../generator/field/generator_poseidon2.go | 32 +++-- .../template/poseidon2/poseidon2_test.go.tmpl | 121 ++++++++++------ 4 files changed, 190 insertions(+), 103 deletions(-) diff --git a/field/mamabear/poseidon2/hash.go b/field/mamabear/poseidon2/hash.go index a7f3f3c62..7689cd25f 100644 --- a/field/mamabear/poseidon2/hash.go +++ b/field/mamabear/poseidon2/hash.go @@ -34,15 +34,15 @@ func NewDefaultPermutation() *Permutation { // // 1. for compression: // - width: 16 -// - nbFullRounds: 6 -// - nbPartialRounds: 21 +// - nbFullRounds: 8 +// - nbPartialRounds: 32 // // 2. for sponge: // - width: 24 -// - nbFullRounds: 6 -// - nbPartialRounds: 21 +// - nbFullRounds: 8 +// - nbPartialRounds: 32 var GetDefaultParameters = sync.OnceValue(func() *Parameters { - return NewParameters(16, 6, 21) + return NewParameters(16, 8, 32) }) var diag16 []fr.Element = make([]fr.Element, 16) diff --git a/field/mamabear/poseidon2/poseidon2_test.go b/field/mamabear/poseidon2/poseidon2_test.go index de9cb2062..f26d59464 100644 --- a/field/mamabear/poseidon2/poseidon2_test.go +++ b/field/mamabear/poseidon2/poseidon2_test.go @@ -19,7 +19,7 @@ func TestMulMulInternalInPlaceWidth16(t *testing.T) { expected = input - h := NewPermutation(16, 6, 21) + h := NewPermutation(16, 8, 32) h.matMulInternalInPlace(expected[:]) var sum fr.Element @@ -44,7 +44,7 @@ func TestMulMulInternalInPlaceWidth24(t *testing.T) { expected = input - h := NewPermutation(24, 6, 21) + h := NewPermutation(24, 8, 32) h.matMulInternalInPlace(expected[:]) var sum fr.Element @@ -81,24 +81,24 @@ func TestPoseidon2Width16(t *testing.T) { input[14].SetUint64(32212254727) input[15].SetUint64(34359738375) - expected[0].SetUint64(379305722682392) - expected[1].SetUint64(434710812510599) - expected[2].SetUint64(550056781736517) - expected[3].SetUint64(80018479061411) - expected[4].SetUint64(197065607198553) - expected[5].SetUint64(184550828188449) - expected[6].SetUint64(169985134836) - expected[7].SetUint64(476980079287507) - expected[8].SetUint64(265028309154312) - expected[9].SetUint64(443033730245920) - expected[10].SetUint64(90181392038270) - expected[11].SetUint64(525395989823088) - expected[12].SetUint64(172442016884945) - expected[13].SetUint64(482775715596317) - expected[14].SetUint64(128007781181334) - expected[15].SetUint64(454435891437885) - - h := NewPermutation(16, 6, 21) + expected[0].SetUint64(50602402429679) + expected[1].SetUint64(205154033184305) + expected[2].SetUint64(171690306064314) + expected[3].SetUint64(290743356206654) + expected[4].SetUint64(52057371620722) + expected[5].SetUint64(357993730760812) + expected[6].SetUint64(30792446669472) + expected[7].SetUint64(560929088275684) + expected[8].SetUint64(351129771614234) + expected[9].SetUint64(167825709741404) + expected[10].SetUint64(435628772411913) + expected[11].SetUint64(263726494550579) + expected[12].SetUint64(253184459815810) + expected[13].SetUint64(470108995398990) + expected[14].SetUint64(245845535172115) + expected[15].SetUint64(339090281054048) + + h := NewPermutation(16, 8, 32) h.Permutation(input[:]) for i := range h.params.Width { if !input[i].Equal(&expected[i]) { @@ -135,32 +135,32 @@ func TestPoseidon2Width24(t *testing.T) { input[22].SetUint64(49392123911) input[23].SetUint64(51539607559) - expected[0].SetUint64(368164608783752) - expected[1].SetUint64(257867004539105) - expected[2].SetUint64(201370080365479) - expected[3].SetUint64(356624278773042) - expected[4].SetUint64(124735134016360) - expected[5].SetUint64(205149928229598) - expected[6].SetUint64(226995027181250) - expected[7].SetUint64(329421188535075) - expected[8].SetUint64(472269735666797) - expected[9].SetUint64(336822262946217) - expected[10].SetUint64(286203220709665) - expected[11].SetUint64(106913007484484) - expected[12].SetUint64(159916680148593) - expected[13].SetUint64(421687845713200) - expected[14].SetUint64(133004491284203) - expected[15].SetUint64(184002038378053) - expected[16].SetUint64(92327348080370) - expected[17].SetUint64(117778158779886) - expected[18].SetUint64(492904809075045) - expected[19].SetUint64(559938928111919) - expected[20].SetUint64(299216349767739) - expected[21].SetUint64(249756944698650) - expected[22].SetUint64(549327931909224) - expected[23].SetUint64(184289500831330) - - h := NewPermutation(24, 6, 21) + expected[0].SetUint64(221949310464874) + expected[1].SetUint64(80986520567350) + expected[2].SetUint64(188405925758112) + expected[3].SetUint64(265381136928946) + expected[4].SetUint64(531029040459252) + expected[5].SetUint64(267299294930657) + expected[6].SetUint64(232053702598666) + expected[7].SetUint64(125866528816944) + expected[8].SetUint64(375645824840808) + expected[9].SetUint64(493982275312219) + expected[10].SetUint64(491313642825887) + expected[11].SetUint64(225430104088042) + expected[12].SetUint64(12298241502257) + expected[13].SetUint64(174077256549267) + expected[14].SetUint64(211511214646258) + expected[15].SetUint64(461473402745453) + expected[16].SetUint64(373037057867494) + expected[17].SetUint64(16199147773576) + expected[18].SetUint64(527007590318455) + expected[19].SetUint64(280256541869745) + expected[20].SetUint64(457055166286236) + expected[21].SetUint64(438914526671414) + expected[22].SetUint64(454144458652198) + expected[23].SetUint64(499223682805339) + + h := NewPermutation(24, 8, 32) h.Permutation(input[:]) for i := range h.params.Width { if !input[i].Equal(&expected[i]) { @@ -170,7 +170,7 @@ func TestPoseidon2Width24(t *testing.T) { } func BenchmarkPoseidon2Width16(b *testing.B) { - h := NewPermutation(16, 6, 21) + h := NewPermutation(16, 8, 32) var tmp [16]fr.Element for i := range tmp { @@ -183,7 +183,7 @@ func BenchmarkPoseidon2Width16(b *testing.B) { } func BenchmarkPoseidon2Width24(b *testing.B) { - h := NewPermutation(24, 6, 21) + h := NewPermutation(24, 8, 32) var tmp [24]fr.Element for i := range tmp { @@ -194,3 +194,37 @@ func BenchmarkPoseidon2Width24(b *testing.B) { h.Permutation(tmp[:]) } } + +// TestDefaultRoundNumbers pins the round numbers to the security analysis they +// come from, so they cannot be changed silently. +// +// Eq. (1) of the Poseidon2 paper (https://eprint.iacr.org/2023/323.pdf), for +// n = ceil(log2(p)) = 49, d = 3 and kappa = 128: +// +// R_F = 8 +// R_P = ceil(1.075 * max(R_interp, R_GB)) +// R_interp = ceil(min{kappa,n}/log2(d)) + ceil(log_d(t)) - 5 = 29 (binding) +// R_GB = 25 +// => R_P = ceil(1.075 * 29) = 32 +// +// The bound scales with min{kappa, log2(p)}, so it is NOT safe to inherit these +// from a narrower field: koalabear saturates at n = 31 and needs only 20 +// partial rounds, while mamabear needs 32. +func TestDefaultRoundNumbers(t *testing.T) { + p := GetDefaultParameters() + if p.Width != 16 { + t.Fatalf("width: got %d, want 16", p.Width) + } + if p.NbFullRounds != 8 { + t.Fatalf("full rounds: got %d, want 8", p.NbFullRounds) + } + if p.NbPartialRounds != 32 { + t.Fatalf("partial rounds: got %d, want 32", p.NbPartialRounds) + } + if DegreeSBox() != 3 { + t.Fatalf("sbox degree: got %d, want 3 (smallest d with gcd(d, p-1) = 1)", DegreeSBox()) + } + if len(p.RoundKeys) != p.NbFullRounds+p.NbPartialRounds { + t.Fatalf("round keys: got %d, want %d", len(p.RoundKeys), p.NbFullRounds+p.NbPartialRounds) + } +} diff --git a/internal/generator/field/generator_poseidon2.go b/internal/generator/field/generator_poseidon2.go index f0d5d7e19..f39a2a90b 100644 --- a/internal/generator/field/generator_poseidon2.go +++ b/internal/generator/field/generator_poseidon2.go @@ -119,14 +119,30 @@ func generatePoseidon2(F *config.Field, outputDir string) error { data.ParamsCompression, } case "mamabear": - // Same round structure and the same semantic diagonal as koalabear; the - // entries below are those values reduced mod the mamabear prime. They - // are canonical residues, not Montgomery-encoded, so they do not depend - // on the radix. + // The diagonal is koalabear's, reduced mod the mamabear prime; the + // entries are canonical residues, not Montgomery-encoded, so they do not + // depend on the radix. + // + // The round numbers are NOT koalabear's. They follow Eq. (1) of the + // Poseidon2 paper (https://eprint.iacr.org/2023/323.pdf) for n = 49, + // t = 16/24, d = 3 at kappa = 128: + // + // R_F = 8 + // R_P = ceil(1.075 * max(R_interp, R_GB)) = ceil(1.075 * 29) = 32 + // + // where R_interp = ceil(min{kappa,n}/log2(d)) + ceil(log_d(t)) - 5 = 29 + // is the binding constraint. The bound grows with min{kappa, log2(p)}, + // so a wider field needs MORE rounds, not fewer: koalabear saturates at + // n = 31 and needs R_P = 20, while mamabear at n = 49 needs 32. Reusing + // koalabear's 21 here would target roughly 33 bits of security. + // + // The same formula reproduces Plonky3's shipped constants for babybear + // (13/21/30 at t = 16/24/32) and koalabear (20/23/31), and Table 1 of + // the paper. data.ParamsCompression = amd64.Poseidon2Parameters{ Width: 16, - FullRounds: 6, - PartialRounds: 21, + FullRounds: 8, + PartialRounds: 32, SBoxDegree: 3, // [-2, 1, 2, 1/2, 3, 4, -1/2, -3, -4, 1/2^8, 1/8, 1/2^24, -1/2^8, -1/8, -1/16, -1/2^24] DiagInternal: []uint64{562932773552127, 1, 2, 281466386776065, 3, 4, 281466386776064, 562932773552126, 562932773552125, 560733817405441, 492566176858113, 562932739998721, 2198956146688, 70366596694016, 35183298347008, 33553408}, @@ -134,8 +150,8 @@ func generatePoseidon2(F *config.Field, outputDir string) error { data.ParamsSponge = amd64.Poseidon2Parameters{ Width: 24, - FullRounds: 6, - PartialRounds: 21, + FullRounds: 8, + PartialRounds: 32, SBoxDegree: 3, // [-2, 1, 2, 1/2, 3, 4, -1/2, -3, -4, 1/2^8, 1/4, 1/8, 1/16, 1/32, 1/64, 1/2^24, -1/2^8, -1/8, -1/16, -1/32, -1/64, -1/2^7, -1/2^9, -1/2^24] DiagInternal: []uint64{562932773552127, 1, 2, 281466386776065, 3, 4, 281466386776064, 562932773552126, 562932773552125, 560733817405441, 422199580164097, 492566176858113, 527749475205121, 545341124378625, 554136948965377, 562932739998721, 2198956146688, 70366596694016, 35183298347008, 17591649173504, 8795824586752, 4397912293376, 1099478073344, 33553408}, diff --git a/internal/generator/field/template/poseidon2/poseidon2_test.go.tmpl b/internal/generator/field/template/poseidon2/poseidon2_test.go.tmpl index 56ac0af96..8000db024 100644 --- a/internal/generator/field/template/poseidon2/poseidon2_test.go.tmpl +++ b/internal/generator/field/template/poseidon2/poseidon2_test.go.tmpl @@ -276,24 +276,24 @@ func TestPoseidon2Width16(t *testing.T) { input[14].SetUint64(32212254727) input[15].SetUint64(34359738375) - expected[0].SetUint64(379305722682392) - expected[1].SetUint64(434710812510599) - expected[2].SetUint64(550056781736517) - expected[3].SetUint64(80018479061411) - expected[4].SetUint64(197065607198553) - expected[5].SetUint64(184550828188449) - expected[6].SetUint64(169985134836) - expected[7].SetUint64(476980079287507) - expected[8].SetUint64(265028309154312) - expected[9].SetUint64(443033730245920) - expected[10].SetUint64(90181392038270) - expected[11].SetUint64(525395989823088) - expected[12].SetUint64(172442016884945) - expected[13].SetUint64(482775715596317) - expected[14].SetUint64(128007781181334) - expected[15].SetUint64(454435891437885) + expected[0].SetUint64(50602402429679) + expected[1].SetUint64(205154033184305) + expected[2].SetUint64(171690306064314) + expected[3].SetUint64(290743356206654) + expected[4].SetUint64(52057371620722) + expected[5].SetUint64(357993730760812) + expected[6].SetUint64(30792446669472) + expected[7].SetUint64(560929088275684) + expected[8].SetUint64(351129771614234) + expected[9].SetUint64(167825709741404) + expected[10].SetUint64(435628772411913) + expected[11].SetUint64(263726494550579) + expected[12].SetUint64(253184459815810) + expected[13].SetUint64(470108995398990) + expected[14].SetUint64(245845535172115) + expected[15].SetUint64(339090281054048) - h := NewPermutation(16, 6, 21) + h := NewPermutation({{- $w0}}, {{- .ParamsCompression.FullRounds}}, {{- .ParamsCompression.PartialRounds}}) {{- else}} input[0].SetUint64(595602690) input[1].SetUint64(847709907) @@ -421,32 +421,32 @@ func TestPoseidon2Width24(t *testing.T) { input[22].SetUint64(49392123911) input[23].SetUint64(51539607559) - expected[0].SetUint64(368164608783752) - expected[1].SetUint64(257867004539105) - expected[2].SetUint64(201370080365479) - expected[3].SetUint64(356624278773042) - expected[4].SetUint64(124735134016360) - expected[5].SetUint64(205149928229598) - expected[6].SetUint64(226995027181250) - expected[7].SetUint64(329421188535075) - expected[8].SetUint64(472269735666797) - expected[9].SetUint64(336822262946217) - expected[10].SetUint64(286203220709665) - expected[11].SetUint64(106913007484484) - expected[12].SetUint64(159916680148593) - expected[13].SetUint64(421687845713200) - expected[14].SetUint64(133004491284203) - expected[15].SetUint64(184002038378053) - expected[16].SetUint64(92327348080370) - expected[17].SetUint64(117778158779886) - expected[18].SetUint64(492904809075045) - expected[19].SetUint64(559938928111919) - expected[20].SetUint64(299216349767739) - expected[21].SetUint64(249756944698650) - expected[22].SetUint64(549327931909224) - expected[23].SetUint64(184289500831330) + expected[0].SetUint64(221949310464874) + expected[1].SetUint64(80986520567350) + expected[2].SetUint64(188405925758112) + expected[3].SetUint64(265381136928946) + expected[4].SetUint64(531029040459252) + expected[5].SetUint64(267299294930657) + expected[6].SetUint64(232053702598666) + expected[7].SetUint64(125866528816944) + expected[8].SetUint64(375645824840808) + expected[9].SetUint64(493982275312219) + expected[10].SetUint64(491313642825887) + expected[11].SetUint64(225430104088042) + expected[12].SetUint64(12298241502257) + expected[13].SetUint64(174077256549267) + expected[14].SetUint64(211511214646258) + expected[15].SetUint64(461473402745453) + expected[16].SetUint64(373037057867494) + expected[17].SetUint64(16199147773576) + expected[18].SetUint64(527007590318455) + expected[19].SetUint64(280256541869745) + expected[20].SetUint64(457055166286236) + expected[21].SetUint64(438914526671414) + expected[22].SetUint64(454144458652198) + expected[23].SetUint64(499223682805339) - h := NewPermutation(24, 6, 21) + h := NewPermutation({{- $w1}}, {{- .ParamsSponge.FullRounds}}, {{- .ParamsSponge.PartialRounds}}) {{- else}} input[0].SetUint64(568554527) input[1].SetUint64(1037389773) @@ -880,3 +880,40 @@ func BenchmarkCompressx16ColumnsWithStateVariableSize(b *testing.B) { } } {{- end}} + +{{- if eq .FF "mamabear"}} + +// TestDefaultRoundNumbers pins the round numbers to the security analysis they +// come from, so they cannot be changed silently. +// +// Eq. (1) of the Poseidon2 paper (https://eprint.iacr.org/2023/323.pdf), for +// n = ceil(log2(p)) = 49, d = 3 and kappa = 128: +// +// R_F = 8 +// R_P = ceil(1.075 * max(R_interp, R_GB)) +// R_interp = ceil(min{kappa,n}/log2(d)) + ceil(log_d(t)) - 5 = 29 (binding) +// R_GB = 25 +// => R_P = ceil(1.075 * 29) = 32 +// +// The bound scales with min{kappa, log2(p)}, so it is NOT safe to inherit these +// from a narrower field: koalabear saturates at n = 31 and needs only 20 +// partial rounds, while mamabear needs 32. +func TestDefaultRoundNumbers(t *testing.T) { + p := GetDefaultParameters() + if p.Width != {{ .ParamsCompression.Width }} { + t.Fatalf("width: got %d, want {{ .ParamsCompression.Width }}", p.Width) + } + if p.NbFullRounds != 8 { + t.Fatalf("full rounds: got %d, want 8", p.NbFullRounds) + } + if p.NbPartialRounds != 32 { + t.Fatalf("partial rounds: got %d, want 32", p.NbPartialRounds) + } + if DegreeSBox() != 3 { + t.Fatalf("sbox degree: got %d, want 3 (smallest d with gcd(d, p-1) = 1)", DegreeSBox()) + } + if len(p.RoundKeys) != p.NbFullRounds+p.NbPartialRounds { + t.Fatalf("round keys: got %d, want %d", len(p.RoundKeys), p.NbFullRounds+p.NbPartialRounds) + } +} +{{- end}}