diff --git a/curve/ecgfp5/affine_point.go b/curve/ecgfp5/affine_point.go index 0d6e229..81d266b 100644 --- a/curve/ecgfp5/affine_point.go +++ b/curve/ecgfp5/affine_point.go @@ -1,6 +1,7 @@ package ecgfp5 import ( + g "github.com/elliottech/poseidon_crypto/field/goldilocks" gFp5 "github.com/elliottech/poseidon_crypto/field/goldilocks_quintic_extension" ) @@ -46,18 +47,19 @@ func Lookup(win []AffinePoint, k int32) AffinePoint { m := km1 - uint32(i) //nolint:gosec c_1 := (m | (^m + 1)) >> 31 c := uint64(c_1) - 1 - if c != 0 { - x = win[i].x - u = win[i].u + for limb := 0; limb < len(x); limb++ { + x[limb] |= g.GoldilocksField(uint64(win[i].x[limb]) & c) + u[limb] |= g.GoldilocksField(uint64(win[i].u[limb]) & c) } - } // If k < 0, then we must negate the point. - c := uint64(sign) | (uint64(sign) << 32) - - if c != 0 { - u = gFp5.Neg(u) + c := uint64(0) - uint64(sign&1) + negativeU := gFp5.Neg(u) + for limb := 0; limb < len(u); limb++ { + u[limb] = g.GoldilocksField( + (uint64(u[limb]) &^ c) | (uint64(negativeU[limb]) & c), + ) } return AffinePoint{x, u} diff --git a/curve/ecgfp5/curve_test.go b/curve/ecgfp5/curve_test.go index ea90cb9..b18a8cc 100644 --- a/curve/ecgfp5/curve_test.go +++ b/curve/ecgfp5/curve_test.go @@ -1,6 +1,9 @@ package ecgfp5 import ( + "encoding/binary" + "math/big" + "math/rand" "testing" g "github.com/elliottech/poseidon_crypto/field/goldilocks" @@ -364,6 +367,18 @@ func TestToAffineAndLookup(t *testing.T) { // Test lookup win := BatchToAffine(tab1) + for k := -len(win); k <= len(win); k++ { + got := Lookup(win, int32(k)) + want := LookupVarTime(win, int32(k)) + if got != want { + t.Fatalf("Lookup(%d) = %v, want exact limbs %v", k, got, want) + } + } + for _, k := range []int32{-int32(len(win)) - 1, int32(len(win)) + 1, 72} { + if got := Lookup(win, k); got != AFFINE_NEUTRAL { + t.Fatalf("Lookup(%d) = %v, want neutral", k, got) + } + } p1Affine := Lookup(win, 72) if !gFp5.Equals(p1Affine.x, gFp5.FP5_ZERO) { @@ -462,6 +477,62 @@ func TestScalarMul(t *testing.T) { } } +func TestMulGMatchesGenericMultiplication(t *testing.T) { + scalars := []ECgFp5Scalar{ + ZERO, + ONE, + TWO, + NEG_ONE, + } + appendIfCanonical := func(value *big.Int) { + if value.Sign() >= 0 && value.Cmp(ORDER) < 0 { + scalars = append(scalars, FromNonCanonicalBigInt(value)) + } + } + appendIfCanonical(new(big.Int).Sub(ORDER, big.NewInt(2))) + for bit := 0; bit <= 318; bit += generatorWindow { + boundary := new(big.Int).Lsh(big.NewInt(1), uint(bit)) + appendIfCanonical(new(big.Int).Sub(new(big.Int).Set(boundary), big.NewInt(1))) + appendIfCanonical(boundary) + appendIfCanonical(new(big.Int).Add(new(big.Int).Set(boundary), big.NewInt(1))) + } + appendIfCanonical(new(big.Int).Lsh(big.NewInt(1), 318)) + appendIfCanonical(new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 319), big.NewInt(1))) + + rng := rand.New(rand.NewSource(1)) //nolint:gosec // deterministic test corpus + var encoded [40]byte + for range 2_000 { + for offset := 0; offset < len(encoded); offset += 8 { + binary.LittleEndian.PutUint64(encoded[offset:], rng.Uint64()) + } + scalars = append(scalars, ScalarElementFromLittleEndianBytes(encoded[:])) + } + + for _, scalar := range scalars { + got := MulG(scalar).Encode() + want := GENERATOR_ECgFp5Point.Mul(scalar).Encode() + if got != want { + t.Fatalf("fixed-generator encoding differs for scalar %v: got %v, want %v", scalar, got, want) + } + } +} + +func BenchmarkGeneratorMul(b *testing.B) { + scalar := SampleScalar() + + b.Run("generic", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = GENERATOR_ECgFp5Point.Mul(scalar) + } + }) + + b.Run("fixed", func(b *testing.B) { + for i := 0; i < b.N; i++ { + _ = MulG(scalar) + } + }) +} + func testVectors() [8]gFp5.Element { // P0 is neutral of G. // P1 is a random point in G (encoded as w1) diff --git a/curve/ecgfp5/point.go b/curve/ecgfp5/point.go index 04b9a7d..af9d431 100644 --- a/curve/ecgfp5/point.go +++ b/curve/ecgfp5/point.go @@ -2,6 +2,7 @@ package ecgfp5 import ( "fmt" + "sync" g "github.com/elliottech/poseidon_crypto/field/goldilocks" gFp5 "github.com/elliottech/poseidon_crypto/field/goldilocks_quintic_extension" @@ -447,8 +448,16 @@ func (p ECgFp5Point) AddAffine(rhs AffinePoint) ECgFp5Point { } const ( - WINDOW = 5 - WIN_SIZE = 1 << (WINDOW - 1) + WINDOW = 5 + WIN_SIZE = 1 << (WINDOW - 1) + generatorWindow = 6 + generatorWindowSize = 1 << (generatorWindow - 1) + generatorScalarDigits = (319 + generatorWindow) / generatorWindow +) + +var ( + generatorFixedWindowOnce sync.Once + generatorFixedWindowAffine []AffinePoint ) // Convert points to affine coordinates. @@ -556,6 +565,57 @@ func (r ECgFp5Point) Mul(s ECgFp5Scalar) ECgFp5Point { return p } +func makeGeneratorFixedWindowAffine() []AffinePoint { + points := make([]ECgFp5Point, generatorScalarDigits*generatorWindowSize) + base := GENERATOR_ECgFp5Point + for position := 0; position < generatorScalarDigits; position++ { + offset := position * generatorWindowSize + points[offset] = base + for i := 1; i < generatorWindowSize; i++ { + if i&1 == 0 { + points[offset+i] = points[offset+i-1].Add(base) + } else { + points[offset+i] = points[offset+(i>>1)].Double() + } + } + if position+1 < generatorScalarDigits { + base.SetMDouble(generatorWindow) + } + } + return BatchToAffine(points) +} + +func getGeneratorFixedWindowAffine() []AffinePoint { + generatorFixedWindowOnce.Do(func() { + generatorFixedWindowAffine = makeGeneratorFixedWindowAffine() + }) + return generatorFixedWindowAffine +} + +// WarmGeneratorTable builds the fixed-generator table ahead of a +// latency-sensitive multiplication. The table is process-global and is built +// at most once. +func WarmGeneratorTable() { + _ = getGeneratorFixedWindowAffine() +} + +// MulG multiplies the fixed curve generator by a scalar. Its position-weighted +// affine table removes both per-call window construction and all point +// doublings from the multiplication path. +func MulG(s ECgFp5Scalar) ECgFp5Point { + var digits [generatorScalarDigits]int32 + s.RecodeSigned(digits[:], generatorWindow) + table := getGeneratorFixedWindowAffine() + + p := Lookup(table[:generatorWindowSize], digits[0]).ToPoint() + for position := 1; position < len(digits); position++ { + offset := position * generatorWindowSize + p = p.AddAffine(Lookup(table[offset:offset+generatorWindowSize], digits[position])) + } + + return p +} + // Returns G*scalarA + b*scalarB func MulAddG(b ECgFp5Point, scalarA, scalarB ECgFp5Scalar) ECgFp5Point { winA := GeneratorWindowAffine diff --git a/curve/ecgfp5/scalar_field.go b/curve/ecgfp5/scalar_field.go index ea2a763..5812022 100644 --- a/curve/ecgfp5/scalar_field.go +++ b/curve/ecgfp5/scalar_field.go @@ -17,7 +17,8 @@ import ( type ECgFp5Scalar [5]uint64 func (s ECgFp5Scalar) IsCanonical() bool { - return ToNonCanonicalBigInt(s).Cmp(ORDER) < 0 + _, borrow := s.SubInner(N) + return borrow != 0 } var ( @@ -70,11 +71,26 @@ func (s ECgFp5Scalar) SplitTo4BitLimbs() [80]uint8 { } func SampleScalar() ECgFp5Scalar { - rng, err := cryptorand.Int(cryptorand.Reader, ORDER) - if err != nil { - panic("failed to read random bytes into buffer") + var encoded [40]byte + for { + if _, err := cryptorand.Read(encoded[:]); err != nil { + panic("failed to read random bytes into buffer") + } + // The scalar order has a 319-bit bit length. Masking the excess bit + // before rejection sampling gives the same uniform distribution as + // crypto/rand.Int without constructing big.Int values. + encoded[39] &= 0x7f + candidate := ECgFp5Scalar{ + binary.LittleEndian.Uint64(encoded[0:8]), + binary.LittleEndian.Uint64(encoded[8:16]), + binary.LittleEndian.Uint64(encoded[16:24]), + binary.LittleEndian.Uint64(encoded[24:32]), + binary.LittleEndian.Uint64(encoded[32:40]), + } + if candidate.IsCanonical() { + return candidate + } } - return FromNonCanonicalBigInt(rng) } var ( @@ -254,13 +270,21 @@ func (s ECgFp5Scalar) MontyMul(rhs ECgFp5Scalar) ECgFp5Scalar { } func FromGfp5(fp5 gFp5.Element) ECgFp5Scalar { - result := new(big.Int) - for i := 4; i >= 0; i-- { - result.Lsh(result, 64) - result.Or(result, new(big.Int).SetUint64(fp5[i].ToCanonicalUint64())) + result := ECgFp5Scalar{ + fp5[0].ToCanonicalUint64(), + fp5[1].ToCanonicalUint64(), + fp5[2].ToCanonicalUint64(), + fp5[3].ToCanonicalUint64(), + fp5[4].ToCanonicalUint64(), } - return FromNonCanonicalBigInt(result) + // A five-limb Goldilocks value is below 2^320, while the scalar + // modulus is greater than 2^320/3. Therefore at most two conditional + // subtractions are required to reduce the value modulo N. + reduced, borrow := result.SubInner(N) + result = Select(borrow, reduced, result) + reduced, borrow = result.SubInner(N) + return Select(borrow, reduced, result) } // Warn: This won't work in 32-bit systems! diff --git a/curve/ecgfp5/scalar_field_test.go b/curve/ecgfp5/scalar_field_test.go index 68c648a..ac62328 100644 --- a/curve/ecgfp5/scalar_field_test.go +++ b/curve/ecgfp5/scalar_field_test.go @@ -36,6 +36,51 @@ func TestScalarElementFromLittleEndianBytesReduces(t *testing.T) { } } +func TestFromGfp5MatchesBigIntReduction(t *testing.T) { + values := []gFp5.Element{ + {}, + {1, 2, 3, 4, 5}, + {g.GoldilocksField(g.ORDER - 1), g.GoldilocksField(g.ORDER - 1), g.GoldilocksField(g.ORDER - 1), g.GoldilocksField(g.ORDER - 1), g.GoldilocksField(g.ORDER - 1)}, + {g.GoldilocksField(^uint64(0)), g.GoldilocksField(^uint64(0)), g.GoldilocksField(^uint64(0)), g.GoldilocksField(^uint64(0)), g.GoldilocksField(^uint64(0))}, + } + for range 256 { + values = append(values, gFp5.Sample()) + } + + for _, value := range values { + reference := new(big.Int) + for i := 4; i >= 0; i-- { + reference.Lsh(reference, 64) + reference.Or(reference, new(big.Int).SetUint64(value[i].ToCanonicalUint64())) + } + want := FromNonCanonicalBigInt(reference) + if got := FromGfp5(value); !got.Equals(want) { + t.Fatalf("FromGfp5(%v) = %v, want %v", value, got, want) + } + } +} + +func TestScalarOrderBitLength(t *testing.T) { + if got := ORDER.BitLen(); got != 319 { + t.Fatalf("scalar order bit length = %d, SampleScalar assumes 319", got) + } +} + +func BenchmarkFromGfp5(b *testing.B) { + value := gFp5.Sample() + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = FromGfp5(value) + } +} + +func BenchmarkSampleScalar(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = SampleScalar() + } +} + func FuzzSerdes(f *testing.F) { f.Add([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40}) f.Add(ORDER.Bytes()) @@ -314,6 +359,24 @@ func TestRecodeSigned(t *testing.T) { } } +func TestGeneratorWindowRecodeReconstructsLargestScalar(t *testing.T) { + scalar := FromNonCanonicalBigInt(new(big.Int).Sub(ORDER, big.NewInt(1))) + var digits [generatorScalarDigits]int32 + scalar.RecodeSigned(digits[:], generatorWindow) + + reconstructed := new(big.Int) + for position, digit := range digits { + term := new(big.Int).Lsh(big.NewInt(int64(digit)), uint(position*generatorWindow)) + reconstructed.Add(reconstructed, term) + } + if want := new(big.Int).Sub(ORDER, big.NewInt(1)); reconstructed.Cmp(want) != 0 { + t.Fatalf("window recoding reconstructed %v, want %v", reconstructed, want) + } + if digits[len(digits)-1] < 0 { + t.Fatalf("top recoded digit = %d, want non-negative", digits[len(digits)-1]) + } +} + func TestFromQuinticExtension(t *testing.T) { scalar := FromGfp5(gFp5.Element{g.NegOneF(), g.NegOneF(), g.NegOneF(), g.NegOneF(), g.NegOneF()}) diff --git a/hash/poseidon2_goldilocks_plonky2/poseidon2.go b/hash/poseidon2_goldilocks_plonky2/poseidon2.go index 9cfe123..6e576fb 100644 --- a/hash/poseidon2_goldilocks_plonky2/poseidon2.go +++ b/hash/poseidon2_goldilocks_plonky2/poseidon2.go @@ -63,7 +63,14 @@ func HashOutFromUint64Array(arr [4]uint64) HashOut { } func HashToQuinticExtension(m []g.GoldilocksField) gFp5.Element { - return gFp5.FromPlonky2GoldilocksField(HashNToMNoPad(m, 5)) + var perm [WIDTH]g.GoldilocksField + for i := 0; i < len(m); i += RATE { + for j := 0; j < RATE && i+j < len(m); j++ { + perm[j] = m[i+j] + } + Permute(&perm) + } + return gFp5.Element{perm[0], perm[1], perm[2], perm[3], perm[4]} } type Poseidon2 struct{} diff --git a/hash/poseidon2_goldilocks_plonky2/poseidon2_test.go b/hash/poseidon2_goldilocks_plonky2/poseidon2_test.go index de22520..34b13fb 100644 --- a/hash/poseidon2_goldilocks_plonky2/poseidon2_test.go +++ b/hash/poseidon2_goldilocks_plonky2/poseidon2_test.go @@ -9,6 +9,7 @@ import ( "testing" g "github.com/elliottech/poseidon_crypto/field/goldilocks" + gFp5 "github.com/elliottech/poseidon_crypto/field/goldilocks_quintic_extension" . "github.com/elliottech/poseidon_crypto/int" ) @@ -294,6 +295,32 @@ func TestHashToQuinticExtension(t *testing.T) { } } +func TestHashToQuinticExtensionKnownAnswers(t *testing.T) { + // Generated with origin/main at ca0ad1b, before the allocation-free + // HashToQuinticExtension implementation was introduced. + tests := []struct { + length int + want gFp5.Element + }{ + {0, gFp5.Element{0, 0, 0, 0, 0}}, + {1, gFp5.Element{18307244851715322540, 3700494528959397187, 11006318565213025038, 16875625778335567248, 15670275921225307291}}, + {5, gFp5.Element{4309675740993348975, 16484708150821318610, 11440901173928016921, 16151918261980042672, 8559850001824328600}}, + {8, gFp5.Element{16663429693141532014, 13480885536584083576, 10689113998286065587, 529140796778810937, 16418358108241697819}}, + {9, gFp5.Element{6337356309265316719, 3051880416340784969, 10114972673943395714, 16235767858383991590, 8567719049959466250}}, + {10, gFp5.Element{1784431504029273515, 6768375076188675504, 9487987232220200039, 16287375587926988344, 14446444611121351896}}, + {244, gFp5.Element{14363238127819134277, 8302689288488285294, 15463871311744153871, 6825082547958386432, 10519142466957289900}}, + } + for _, test := range tests { + input := make([]g.GoldilocksField, test.length) + for i := range input { + input[i] = g.GoldilocksField(uint64(i+1) * 123456789) + } + if got := HashToQuinticExtension(input); got != test.want { + t.Fatalf("HashToQuinticExtension length %d = %v, want %v", test.length, got, test.want) + } + } +} + func TestConstantsAreInTheField(t *testing.T) { for r := 0; r < ROUNDS_F; r++ { for i := 0; i < WIDTH; i++ { diff --git a/signature/schnorr/schnorr.go b/signature/schnorr/schnorr.go index b18b345..5a74ffd 100644 --- a/signature/schnorr/schnorr.go +++ b/signature/schnorr/schnorr.go @@ -42,6 +42,8 @@ package signature import ( "errors" "fmt" + "os" + "sync/atomic" curve "github.com/elliottech/poseidon_crypto/curve/ecgfp5" g "github.com/elliottech/poseidon_crypto/field/goldilocks" @@ -54,6 +56,56 @@ type Signature struct { E curve.ECgFp5Scalar } +var ( + ErrNilPreparedNonce = errors.New("prepared Schnorr nonce is nil") + ErrPreparedNonceConsumed = errors.New("prepared Schnorr nonce has already been consumed") + ErrPreparedNonceForked = errors.New("prepared Schnorr nonce belongs to another process") +) + +// PreparedNonce holds an ephemeral Schnorr scalar and its fixed-generator +// multiplication. It may be prepared before a latency-sensitive signing call. +// A PreparedNonce is single-use: aliases and copied handles share the same +// consumption state, so only one call to SchnorrSignHashedMessagePrepared can +// succeed. +type PreparedNonce struct { + state *preparedNonceState +} + +type preparedNonceState struct { + consumed atomic.Bool + processID int + k curve.ECgFp5Scalar + r gFp5.Element +} + +// PrepareNonce samples a fresh Schnorr nonce and computes its commitment. +func PrepareNonce() *PreparedNonce { + k := curve.SampleScalar() + return &PreparedNonce{state: &preparedNonceState{ + processID: os.Getpid(), + k: k, + r: curve.MulG(k).Encode(), + }} +} + +func (nonce *PreparedNonce) consume() (curve.ECgFp5Scalar, gFp5.Element, error) { + if nonce == nil || nonce.state == nil { + return curve.ZERO, gFp5.Element{}, ErrNilPreparedNonce + } + if !nonce.state.consumed.CompareAndSwap(false, true) { + return curve.ZERO, gFp5.Element{}, ErrPreparedNonceConsumed + } + + processID := nonce.state.processID + k, r := nonce.state.k, nonce.state.r + nonce.state.k = curve.ZERO + nonce.state.r = gFp5.Element{} + if processID != os.Getpid() { + return curve.ZERO, gFp5.Element{}, ErrPreparedNonceForked + } + return k, r, nil +} + func (s Signature) IsCanonical() bool { return s.E.IsCanonical() && s.S.IsCanonical() } @@ -90,41 +142,28 @@ func SigFromBytes(b []byte) (Signature, error) { // Public key is actually an EC point (4 Fp5 elements), but it can be encoded as a single Fp5 element. func SchnorrPkFromSk(sk curve.ECgFp5Scalar) gFp5.Element { - return curve.GENERATOR_ECgFp5Point.Mul(sk).Encode() + return curve.MulG(sk).Encode() } func SchnorrSignHashedMessage(hashedMsg gFp5.Element, sk curve.ECgFp5Scalar) Signature { // Sample random scalar `k` and compute `r = k * G` k := curve.SampleScalar() - r := curve.GENERATOR_ECgFp5Point.Mul(k).Encode() - - // Compute `e = H(r || H(m))`, which is a scalar point - preImage := make([]g.GoldilocksField, 5+5) - copy(preImage[:5], r[:]) - copy(preImage[5:], hashedMsg[:]) - - // TODO: Something to be considered later (and require coordinate with Rust) - // - // It is possible that we only use 128 bits for e (instread of 320 bits) - // That is, we can build e with the first 3 limbs of p2.HashToQuinticExtension(preImage) - // This should improve the performance of schnorr signature. - // - // see - // - // - Hash Function Requirements for Schnorr Signatures - // Gregory Neven, Nigel P. Smart, and Bogdan Warinschi - // - Short Schnorr Signatures Require a Hash Function with More Than Just Random-Prefix Resistance - // Daniel R. L. Brown + r := curve.MulG(k).Encode() + return schnorrSignHashedMessageWithNonce(hashedMsg, sk, k, r) +} - e := curve.FromGfp5(p2.HashToQuinticExtension(preImage)) - return Signature{ - S: k.Sub(e.Mul(sk)), - E: e, +// SchnorrSignHashedMessagePrepared signs with a one-use nonce prepared by +// PrepareNonce. The nonce is consumed even if signing later panics, ensuring it +// cannot accidentally be reused with another message. +func SchnorrSignHashedMessagePrepared(hashedMsg gFp5.Element, sk curve.ECgFp5Scalar, nonce *PreparedNonce) (Signature, error) { + k, r, err := nonce.consume() + if err != nil { + return ZERO_SIG, err } + return schnorrSignHashedMessageWithNonce(hashedMsg, sk, k, r), nil } -func SchnorrSignHashedMessage2(hashedMsg gFp5.Element, sk, k curve.ECgFp5Scalar) Signature { - r := curve.GENERATOR_ECgFp5Point.Mul(k).Encode() +func schnorrSignHashedMessageWithNonce(hashedMsg gFp5.Element, sk, k curve.ECgFp5Scalar, r gFp5.Element) Signature { // Compute `e = H(r || H(m))`, which is a scalar point preImage := make([]g.GoldilocksField, 5+5) copy(preImage[:5], r[:]) @@ -150,6 +189,11 @@ func SchnorrSignHashedMessage2(hashedMsg gFp5.Element, sk, k curve.ECgFp5Scalar) } } +func SchnorrSignHashedMessage2(hashedMsg gFp5.Element, sk, k curve.ECgFp5Scalar) Signature { + r := curve.MulG(k).Encode() + return schnorrSignHashedMessageWithNonce(hashedMsg, sk, k, r) +} + func Validate(pubKey, hashedMsg, sig []byte) error { pk, err := gFp5.FromCanonicalLittleEndianBytes(pubKey) if err != nil { diff --git a/signature/schnorr/schnorr_test.go b/signature/schnorr/schnorr_test.go index 0eab7a5..7941e32 100644 --- a/signature/schnorr/schnorr_test.go +++ b/signature/schnorr/schnorr_test.go @@ -2,7 +2,12 @@ package signature import ( "encoding/binary" + "encoding/hex" + "errors" "math/big" + "os" + "sync" + "sync/atomic" "testing" curve "github.com/elliottech/poseidon_crypto/curve/ecgfp5" @@ -26,6 +31,102 @@ func TestSchnorrSignAndVerify(t *testing.T) { } } +func TestSchnorrMatchesGoldilocksCryptoKnownAnswers(t *testing.T) { + // These vectors come from goldilocks-crypto v0.1.2, the independent Rust + // reference used by elliottech/p3-lighter-circuits for witness fixtures. + tests := []struct { + index byte + publicKey string + signature string + }{ + {1, "04000000000000000000000000000000000000000000000000000000000000000000000000000000", "58d95fcb40e5f6d045329748301f36f844a1df56b43a6ef5f8922d3c6836c4fd2d35ae12eabe23319a432cc955f41817576e8d8e093d52f0f464d87832c5918a1d6dd2c388c93b82d9ca516d1341dc4e"}, + {2, "384c87fe1213197f4e1b457e9d43548fc00067c00ee5c1d872895e08ab103be54336d3d4b9d5bc8c", "76c92759ee9d1601fad7efdaf3398288c440a6d76f757f6d57990d7a65158c29516d57c2d403656f3738f8679f8a84e79fb4ace93f3f47a4d7e5e4e32e4540c96a33f9423ef539eb5e49d41e137e4d48"}, + {8, "c97b633e9b0098e743e5b9750cb8b3678cd2e9e3ca8d674b73ed76dc778701e5f743c0d67623f7a6", "63b0ac4aa52c5d937cb633fd48eb2eb430c6de871013f69ba56611e7abc44188adbf2f2d81a56b63f9065fce034c9a446b4507912c45ada08f29e97c943d81bc33d31de364c7f71e0d085aea4e8b9223"}, + } + + for _, test := range tests { + var skBytes, nonceBytes, msgBytes [40]byte + skBytes[0] = test.index + nonceBytes[0] = test.index * 17 + nonceBytes[1] = test.index * 29 + msgBytes[0] = test.index + msgBytes[8] = test.index * 3 + msgBytes[16] = test.index * 5 + + sk := curve.ScalarElementFromLittleEndianBytes(skBytes[:]) + nonce := curve.ScalarElementFromLittleEndianBytes(nonceBytes[:]) + message, err := gFp5.FromCanonicalLittleEndianBytes(msgBytes[:]) + if err != nil { + t.Fatalf("decode message: %v", err) + } + if got := hex.EncodeToString(SchnorrPkFromSk(sk).ToLittleEndianBytes()); got != test.publicKey { + t.Fatalf("public key for vector %d = %s, want %s", test.index, got, test.publicKey) + } + if got := hex.EncodeToString(SchnorrSignHashedMessage2(message, sk, nonce).ToBytes()); got != test.signature { + t.Fatalf("signature for vector %d = %s, want %s", test.index, got, test.signature) + } + } +} + +func TestPreparedNonceSignsOnce(t *testing.T) { + sk := curve.SampleScalar() + hashedMsg := p2.HashToQuinticExtension([]g.GoldilocksField{1, 2, 3}) + nonce := PrepareNonce() + alias := *nonce + + sig, err := SchnorrSignHashedMessagePrepared(hashedMsg, sk, nonce) + if err != nil { + t.Fatalf("prepared signing failed: %v", err) + } + if !IsSchnorrSignatureValid(SchnorrPkFromSk(sk), hashedMsg, sig) { + t.Fatal("prepared signature is invalid") + } + + if _, err := SchnorrSignHashedMessagePrepared(hashedMsg, sk, &alias); !errors.Is(err, ErrPreparedNonceConsumed) { + t.Fatalf("copied nonce handle was reusable: %v", err) + } +} + +func TestPreparedNonceConcurrentConsumption(t *testing.T) { + sk := curve.SampleScalar() + hashedMsg := p2.HashToQuinticExtension([]g.GoldilocksField{4, 5, 6}) + nonce := PrepareNonce() + + var successes atomic.Int32 + var workers sync.WaitGroup + for range 32 { + workers.Add(1) + go func() { + defer workers.Done() + if _, err := SchnorrSignHashedMessagePrepared(hashedMsg, sk, nonce); err == nil { + successes.Add(1) + } else if !errors.Is(err, ErrPreparedNonceConsumed) { + t.Errorf("unexpected signing error: %v", err) + } + }() + } + workers.Wait() + if got := successes.Load(); got != 1 { + t.Fatalf("prepared nonce succeeded %d times, want exactly 1", got) + } +} + +func TestNilPreparedNonce(t *testing.T) { + _, err := SchnorrSignHashedMessagePrepared(gFp5.Element{}, curve.ONE, nil) + if !errors.Is(err, ErrNilPreparedNonce) { + t.Fatalf("nil nonce error = %v", err) + } +} + +func TestPreparedNonceCannotCrossProcessBoundary(t *testing.T) { + nonce := PrepareNonce() + nonce.state.processID = os.Getpid() + 1 + _, err := SchnorrSignHashedMessagePrepared(gFp5.Element{}, curve.ONE, nonce) + if !errors.Is(err, ErrPreparedNonceForked) { + t.Fatalf("cross-process nonce error = %v", err) + } +} + func FuzzTestSchnorrSignAndVerify(f *testing.F) { f.Add([]byte{1, 2, 3, 4}, []byte{5, 6, 7, 8}) @@ -237,3 +338,27 @@ func BenchmarkSignatureSign(b *testing.B) { _ = SchnorrSignHashedMessage(hashedMsg, sk) } } + +func BenchmarkSignatureSignPreparedOnline(b *testing.B) { + sk := curve.SampleScalar() + msg := make([]g.GoldilocksField, 244) + for i := range msg { + msg[i] = g.SampleF() + } + hashedMsg := p2.HashToQuinticExtension(msg) + k := curve.SampleScalar() + r := curve.MulG(k).Encode() + + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = schnorrSignHashedMessageWithNonce(hashedMsg, sk, k, r) + } +} + +func BenchmarkPrepareNonce(b *testing.B) { + b.ReportAllocs() + for i := 0; i < b.N; i++ { + _ = PrepareNonce() + } +}