Skip to main content

nautilus_lighter/signing/schnorr/
sig.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Schnorr `(s, e)` signature container, signing routine, and verification.
17//!
18//! The signature scheme follows the elliottech `poseidon_crypto` reference:
19//!
20//! Sign:
21//!     `r = (k * G).encode()` (`Fp5`)
22//!     `e = HashToQuintic(r || hashed_msg)` reduced into the scalar field
23//!     `s = k - e * sk`
24//!
25//! Verify:
26//!     decode `pk` to a curve point; reject malformed encodings
27//!     `r_v = (s * G + e * pk).encode()`
28//!     `e_v = HashToQuintic(r_v || hashed_msg)` reduced into the scalar field
29//!     accept iff `e_v == e`
30//!
31//! Signing routes through the constant-time scalar mul; verification uses the
32//! variable-time path because all of its inputs are public. Signature bytes are
33//! laid out as `s_le || e_le` over 80 bytes, matching the Go reference's
34//! `Signature.ToBytes` / `SigFromBytes`.
35
36use crate::signing::{
37    curve::{Point, SCALAR_BYTES, Scalar},
38    field::Fp5,
39    hash::hash_two_to_quintic,
40};
41
42/// Canonical wire length of a [`Signature`]: 40 bytes for `s`, 40 for `e`.
43pub const SIG_BYTES: usize = 2 * SCALAR_BYTES;
44
45/// Schnorr signature over the ECgFp5 curve.
46///
47/// `s` and `e` are canonical scalars (`< n`). Wire format is `s_le || e_le`.
48#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
49pub struct Signature {
50    /// `s = k - e * sk` component.
51    pub s: Scalar,
52    /// `e = H(r || H(m))` component reduced into the scalar field.
53    pub e: Scalar,
54}
55
56impl Signature {
57    /// Encode to the canonical 80-byte little-endian layout `s_le || e_le`.
58    #[must_use]
59    pub fn to_le_bytes(&self) -> [u8; SIG_BYTES] {
60        let mut out = [0u8; SIG_BYTES];
61        out[..SCALAR_BYTES].copy_from_slice(&self.s.to_le_bytes());
62        out[SCALAR_BYTES..].copy_from_slice(&self.e.to_le_bytes());
63        out
64    }
65
66    /// Decode an 80-byte signature, reducing each scalar limb sequence modulo
67    /// the group order if it exceeds the canonical range.
68    ///
69    /// Mirrors the Go reference's `SigFromBytes` semantics: non-canonical
70    /// encodings are accepted and reduced. The verification routine performs
71    /// the actual canonicality / membership check.
72    #[must_use]
73    pub fn from_le_bytes_reduce(bytes: [u8; SIG_BYTES]) -> Self {
74        let mut s_buf = [0u8; SCALAR_BYTES];
75        let mut e_buf = [0u8; SCALAR_BYTES];
76        s_buf.copy_from_slice(&bytes[..SCALAR_BYTES]);
77        e_buf.copy_from_slice(&bytes[SCALAR_BYTES..]);
78
79        Self {
80            s: Scalar::from_le_bytes_reduce(s_buf),
81            e: Scalar::from_le_bytes_reduce(e_buf),
82        }
83    }
84
85    /// Test whether both scalar components are canonical (`< n`).
86    #[must_use]
87    pub fn is_canonical(&self) -> bool {
88        self.s.is_canonical() && self.e.is_canonical()
89    }
90}
91
92/// Sign a pre-hashed message under the supplied per-signature nonce `k`.
93///
94/// See [`super::PrivateKey::sign`] for the public entry point.
95#[must_use]
96pub(super) fn sign(sk: Scalar, hashed_msg: Fp5, k: Scalar) -> Signature {
97    let r = Point::mulgen_ct(k).encode();
98    let e = Scalar::from_fp5(hash_two_to_quintic(r, hashed_msg));
99
100    Signature { s: k - e * sk, e }
101}
102
103/// Verify a signature against the public-key encoding `pk_w` for the given
104/// pre-hashed message. See [`super::PublicKey::verify`] for the public entry.
105///
106/// No explicit "neutral public key" rejection is applied. ECgFp5 is a
107/// prime-order group, so `Point::decode` either returns a member of the prime
108/// subgroup or rejects the encoding outright; the recurring "subgroup check"
109/// concern from cofactor curves does not apply. The Go reference
110/// `IsSchnorrSignatureValid` follows the same contract, and Phase E Layer 2
111/// oracle tests confirm the mainnet signer accepts the same set of encodings.
112#[must_use]
113pub(super) fn verify(pk_w: Fp5, hashed_msg: Fp5, sig: &Signature) -> bool {
114    if !sig.is_canonical() {
115        return false;
116    }
117
118    let pk = match Point::decode(pk_w) {
119        Some(p) => p,
120        None => return false,
121    };
122
123    let r_v = (Point::mulgen(sig.s) + pk.scalar_mul(sig.e)).encode();
124    let e_v = Scalar::from_fp5(hash_two_to_quintic(r_v, hashed_msg));
125
126    e_v == sig.e
127}
128
129#[cfg(test)]
130mod tests {
131    use proptest::prelude::*;
132    use rstest::rstest;
133    use serde::Deserialize;
134
135    use super::*;
136    use crate::signing::{
137        fixtures::{
138            arb_fp5, arb_scalar, bytes_to_hex, decode_fp5_bytes, decode_scalar_bytes,
139            decode_sig_bytes,
140        },
141        schnorr::{PrivateKey, PublicKey},
142    };
143
144    const VECTORS_JSON: &str = include_str!(concat!(
145        env!("CARGO_MANIFEST_DIR"),
146        "/test_data/signing_schnorr_vectors.json",
147    ));
148
149    #[derive(Debug, Deserialize)]
150    struct VectorsFile {
151        vectors: Vectors,
152    }
153
154    #[derive(Debug, Deserialize)]
155    struct Vectors {
156        keygen: Vec<KeyGenVector>,
157        sign: Vec<SignVector>,
158    }
159
160    #[derive(Debug, Deserialize)]
161    struct KeyGenVector {
162        sk: String,
163        pk: String,
164    }
165
166    #[derive(Debug, Deserialize)]
167    struct SignVector {
168        sk: String,
169        hashed_msg: String,
170        k: String,
171        sig: String,
172    }
173
174    #[rstest]
175    fn keygen_matches_go_reference_vectors() {
176        let suite: VectorsFile = serde_json::from_str(VECTORS_JSON).expect("parse vectors");
177        assert!(!suite.vectors.keygen.is_empty(), "keygen vectors empty");
178
179        for (i, v) in suite.vectors.keygen.iter().enumerate() {
180            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
181            let pk = sk.public_key();
182            assert_eq!(
183                bytes_to_hex(&pk.to_le_bytes()),
184                v.pk,
185                "vector {i}: pk encoding diverged",
186            );
187        }
188    }
189
190    #[rstest]
191    fn sign_matches_go_reference_vectors() {
192        let suite: VectorsFile = serde_json::from_str(VECTORS_JSON).expect("parse vectors");
193        assert!(!suite.vectors.sign.is_empty(), "sign vectors empty");
194
195        for (i, v) in suite.vectors.sign.iter().enumerate() {
196            let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
197            let hashed_msg = decode_fp5_bytes(&v.hashed_msg);
198            let k = Scalar::from_le_bytes_reduce(decode_scalar_bytes(&v.k));
199
200            let sig = sk.sign(hashed_msg, k);
201            assert_eq!(
202                bytes_to_hex(&sig.to_le_bytes()),
203                v.sig,
204                "vector {i}: signature bytes diverged",
205            );
206
207            let pk = sk.public_key();
208            assert!(pk.verify(hashed_msg, &sig), "vector {i}: verify failed");
209        }
210    }
211
212    #[rstest]
213    fn signature_round_trip_through_bytes() {
214        let suite: VectorsFile = serde_json::from_str(VECTORS_JSON).expect("parse vectors");
215        let v = &suite.vectors.sign[0];
216
217        let bytes = decode_sig_bytes(&v.sig);
218        let sig = Signature::from_le_bytes_reduce(bytes);
219        assert!(sig.is_canonical(), "fixture sig must be canonical");
220        assert_eq!(sig.to_le_bytes(), bytes, "round trip diverged");
221    }
222
223    #[rstest]
224    fn verify_rejects_corrupted_signature() {
225        let suite: VectorsFile = serde_json::from_str(VECTORS_JSON).expect("parse vectors");
226        let v = &suite.vectors.sign[0];
227
228        let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
229        let hashed_msg = decode_fp5_bytes(&v.hashed_msg);
230        let k = Scalar::from_le_bytes_reduce(decode_scalar_bytes(&v.k));
231        let mut sig = sk.sign(hashed_msg, k);
232        let pk = sk.public_key();
233
234        sig.s += Scalar::ONE;
235        assert!(!pk.verify(hashed_msg, &sig), "tampered sig must not verify");
236    }
237
238    #[rstest]
239    fn verify_rejects_wrong_message() {
240        let suite: VectorsFile = serde_json::from_str(VECTORS_JSON).expect("parse vectors");
241        let v = &suite.vectors.sign[0];
242
243        let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
244        let hashed_msg = decode_fp5_bytes(&v.hashed_msg);
245        let k = Scalar::from_le_bytes_reduce(decode_scalar_bytes(&v.k));
246        let sig = sk.sign(hashed_msg, k);
247        let pk = sk.public_key();
248
249        let other_msg = Fp5::from_u64s_reduce([1, 2, 3, 4, 5]);
250        assert!(
251            !pk.verify(other_msg, &sig),
252            "verify must fail for a different message",
253        );
254    }
255
256    #[rstest]
257    fn verify_rejects_malformed_pubkey() {
258        // Use one of the bad encodings checked by the curve decode tests.
259        let bad = Fp5::from_u64s_reduce([
260            13_557_832_913_345_268_708,
261            15_669_280_705_791_538_619,
262            8_534_654_657_267_986_396,
263            12_533_218_303_838_131_749,
264            5_058_070_698_878_426_028,
265        ]);
266        let pk = crate::signing::schnorr::PublicKey::from_fp5(bad);
267
268        let sig = Signature {
269            s: Scalar::ONE,
270            e: Scalar::ONE,
271        };
272        assert!(
273            !pk.verify(Fp5::ZERO, &sig),
274            "verify must fail when pk does not decode",
275        );
276    }
277
278    /// Returns a non-canonical `Scalar` whose limb sequence equals the group
279    /// order: `ORDER` itself fails `is_canonical` (the predicate is strict
280    /// less-than) and is the cheapest non-canonical witness available without
281    /// reaching for `add_inner`.
282    fn non_canonical_scalar() -> Scalar {
283        crate::signing::curve::ORDER
284    }
285
286    #[rstest]
287    #[case(true, true, true)]
288    #[case(false, true, false)]
289    #[case(true, false, false)]
290    #[case(false, false, false)]
291    fn signature_is_canonical_truth_table(
292        #[case] s_canonical: bool,
293        #[case] e_canonical: bool,
294        #[case] expected: bool,
295    ) {
296        let canon = Scalar::ONE;
297        let non_canon = non_canonical_scalar();
298        let sig = Signature {
299            s: if s_canonical { canon } else { non_canon },
300            e: if e_canonical { canon } else { non_canon },
301        };
302        assert_eq!(sig.is_canonical(), expected);
303    }
304
305    #[rstest]
306    fn verify_rejects_non_canonical_signature() {
307        let suite: VectorsFile = serde_json::from_str(VECTORS_JSON).expect("parse vectors");
308        let v = &suite.vectors.sign[0];
309
310        let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
311        let hashed_msg = decode_fp5_bytes(&v.hashed_msg);
312        let pk = sk.public_key();
313
314        let bad = non_canonical_scalar();
315        let sig = Signature { s: bad, e: bad };
316
317        assert!(
318            !pk.verify(hashed_msg, &sig),
319            "verify must reject non-canonical signature scalars",
320        );
321    }
322
323    proptest! {
324        /// Round-trip property: any canonical `(sk, k != 0, hashed_msg)` yields
325        /// a signature that verifies under the matching public key.
326        #[rstest]
327        fn prop_sign_verify_round_trip(
328            sk in arb_scalar(),
329            k in arb_scalar(),
330            hashed_msg in arb_fp5(),
331        ) {
332            // Schnorr is undefined for k == 0 (caller contract); skip that
333            // case rather than asserting on a malformed input.
334            prop_assume!(!k.is_zero());
335            // sk == 0 is also pathological (pk = neutral); skip to avoid
336            // tripping the deferred neutral-pk concern unrelated to the
337            // round-trip property.
338            prop_assume!(!sk.is_zero());
339
340            let private_key = PrivateKey::from_scalar(sk);
341            let pk = private_key.public_key();
342            let sig = private_key.sign(hashed_msg, k);
343            prop_assert!(pk.verify(hashed_msg, &sig));
344        }
345
346        /// Round-trip through bytes: any canonical signature decodes back to
347        /// the same value via `from_le_bytes_reduce`.
348        #[rstest]
349        fn prop_signature_bytes_round_trip(
350            sk in arb_scalar(),
351            k in arb_scalar(),
352            hashed_msg in arb_fp5(),
353        ) {
354            prop_assume!(!sk.is_zero());
355            prop_assume!(!k.is_zero());
356            let sig = PrivateKey::from_scalar(sk).sign(hashed_msg, k);
357            prop_assert!(sig.is_canonical());
358            let decoded = Signature::from_le_bytes_reduce(sig.to_le_bytes());
359            prop_assert_eq!(decoded, sig);
360        }
361    }
362
363    proptest! {
364        // Bit-flip rejection: perturbing any byte of `s`, `e`, or `pk` by a
365        // single-bit flip MUST cause verification to fail. Each case runs a
366        // sign + verify, so we keep the case count moderate.
367        #![proptest_config(ProptestConfig {
368            cases: 64,
369            ..ProptestConfig::default()
370        })]
371
372        /// Flipping any bit in `sig.s` causes verify to fail.
373        #[rstest]
374        fn prop_verify_rejects_single_bit_flip_in_s(
375            sk in arb_scalar(),
376            k in arb_scalar(),
377            hashed_msg in arb_fp5(),
378            byte_idx in 0usize..SCALAR_BYTES,
379            bit in 0u8..8,
380        ) {
381            prop_assume!(!sk.is_zero());
382            prop_assume!(!k.is_zero());
383            let private_key = PrivateKey::from_scalar(sk);
384            let pk = private_key.public_key();
385            let sig = private_key.sign(hashed_msg, k);
386
387            let mut bytes = sig.to_le_bytes();
388            bytes[byte_idx] ^= 1 << bit;
389            let tampered = Signature::from_le_bytes_reduce(bytes);
390            prop_assume!(tampered != sig);
391            prop_assert!(!pk.verify(hashed_msg, &tampered));
392        }
393
394        /// Flipping any bit in `sig.e` causes verify to fail.
395        #[rstest]
396        fn prop_verify_rejects_single_bit_flip_in_e(
397            sk in arb_scalar(),
398            k in arb_scalar(),
399            hashed_msg in arb_fp5(),
400            byte_idx in SCALAR_BYTES..SIG_BYTES,
401            bit in 0u8..8,
402        ) {
403            prop_assume!(!sk.is_zero());
404            prop_assume!(!k.is_zero());
405            let private_key = PrivateKey::from_scalar(sk);
406            let pk = private_key.public_key();
407            let sig = private_key.sign(hashed_msg, k);
408
409            let mut bytes = sig.to_le_bytes();
410            bytes[byte_idx] ^= 1 << bit;
411            let tampered = Signature::from_le_bytes_reduce(bytes);
412            prop_assume!(tampered != sig);
413            prop_assert!(!pk.verify(hashed_msg, &tampered));
414        }
415
416        /// Perturbing the public key by a non-zero offset causes verify to
417        /// fail (overwhelmingly: either the perturbed `Fp5` does not decode,
418        /// or it decodes to a different group point and the recovered `e_v`
419        /// differs from `sig.e`).
420        #[rstest]
421        fn prop_verify_rejects_perturbed_pubkey(
422            sk in arb_scalar(),
423            k in arb_scalar(),
424            hashed_msg in arb_fp5(),
425            offset in arb_fp5(),
426        ) {
427            prop_assume!(!sk.is_zero());
428            prop_assume!(!k.is_zero());
429            prop_assume!(!offset.is_zero());
430
431            let private_key = PrivateKey::from_scalar(sk);
432            let pk = private_key.public_key();
433            let sig = private_key.sign(hashed_msg, k);
434            prop_assert!(pk.verify(hashed_msg, &sig));
435
436            let perturbed = PublicKey::from_fp5(pk.as_fp5() + offset);
437            // The original pk encoding plus offset is almost surely a
438            // different element; if by accident it equals pk, skip.
439            prop_assume!(perturbed.as_fp5() != pk.as_fp5());
440            prop_assert!(!perturbed.verify(hashed_msg, &sig));
441        }
442    }
443
444    /// Pin the documented "no neutral public-key rejection" contract:
445    /// `Fp5::ZERO` decodes as the curve neutral, and verifying a random
446    /// signature under it returns `false` cleanly (no panic).
447    #[rstest]
448    fn verify_under_neutral_pubkey_returns_false() {
449        let pk = PublicKey::from_fp5(Fp5::ZERO);
450        let sig = Signature {
451            s: Scalar::ONE,
452            e: Scalar::ONE,
453        };
454        assert!(
455            !pk.verify(Fp5::from_u64s_reduce([1, 2, 3, 4, 5]), &sig),
456            "verify under neutral pk must return false for an unrelated sig",
457        );
458    }
459
460    #[rstest]
461    fn from_le_bytes_reduce_normalizes_non_canonical_signature() {
462        let suite: VectorsFile = serde_json::from_str(VECTORS_JSON).expect("parse vectors");
463        let v = &suite.vectors.sign[0];
464
465        let sk = PrivateKey::from_le_bytes_reduce(decode_scalar_bytes(&v.sk));
466        let hashed_msg = decode_fp5_bytes(&v.hashed_msg);
467        let k = Scalar::from_le_bytes_reduce(decode_scalar_bytes(&v.k));
468        let canonical = sk.sign(hashed_msg, k);
469        let pk = sk.public_key();
470
471        // Mirrors the Go `TestBytes` case: add ORDER to each scalar without
472        // reducing, encode raw, and feed back through the reducing decoder.
473        let s_inflated = canonical.s.add_inner(crate::signing::curve::ORDER);
474        let e_inflated = canonical.e.add_inner(crate::signing::curve::ORDER);
475        let mut bytes = [0u8; SIG_BYTES];
476        bytes[..SCALAR_BYTES].copy_from_slice(&s_inflated.to_le_bytes());
477        bytes[SCALAR_BYTES..].copy_from_slice(&e_inflated.to_le_bytes());
478
479        let reduced = Signature::from_le_bytes_reduce(bytes);
480        assert!(
481            reduced.is_canonical(),
482            "reduced signature must be canonical",
483        );
484        assert_eq!(
485            reduced, canonical,
486            "reduction must recover the canonical sig"
487        );
488        assert!(
489            pk.verify(hashed_msg, &reduced),
490            "reduced signature must still verify",
491        );
492    }
493}