Skip to main content

nautilus_lighter/signing/schnorr/
key.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 keypair types.
17//!
18//! [`PrivateKey`] is a thin wrapper over [`Scalar`] that derives its public
19//! counterpart by computing `pk = (sk * G).encode()` through the
20//! constant-time scalar multiplication path. [`PublicKey`] holds the canonical
21//! `Fp5` encoding `w` and decodes back to a curve point at verification time.
22//!
23//! Both types expose the 40-byte canonical little-endian wire format Lighter
24//! uses on the L2. Decoding accepts non-canonical scalar bytes and reduces them
25//! modulo the group order, mirroring `ScalarElementFromLittleEndianBytes` from
26//! the Go reference.
27
28use std::fmt::Debug;
29
30use nautilus_core::string::secret::REDACTED;
31use zeroize::{Zeroize, ZeroizeOnDrop};
32
33use super::sig::Signature;
34use crate::signing::{
35    curve::{Point, SCALAR_BYTES, Scalar},
36    field::Fp5,
37};
38
39/// Canonical 40-byte little-endian length of a [`PublicKey`] (`Fp5` encoding).
40const PUBLIC_KEY_BYTES: usize = 40;
41
42/// A Schnorr private key over the ECgFp5 scalar field.
43///
44/// The wrapped [`Scalar`] is canonical (`< n`). Intentionally non-`Copy` so the
45/// type cannot be silently duplicated past its zeroizing owner. The `Debug`
46/// impl is redacted so accidental logging cannot leak the secret limbs.
47#[derive(Clone)]
48pub struct PrivateKey(Scalar);
49
50impl Zeroize for PrivateKey {
51    fn zeroize(&mut self) {
52        self.0.0.zeroize();
53    }
54}
55
56impl Drop for PrivateKey {
57    fn drop(&mut self) {
58        self.zeroize();
59    }
60}
61
62impl ZeroizeOnDrop for PrivateKey {}
63
64impl Debug for PrivateKey {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "PrivateKey({REDACTED})")
67    }
68}
69
70impl PrivateKey {
71    /// Wrap a canonical scalar as a private key. The caller is responsible for
72    /// ensuring `s` is canonical and uniformly random in `1..n`.
73    #[inline]
74    #[must_use]
75    pub fn from_scalar(s: Scalar) -> Self {
76        Self(s)
77    }
78
79    /// Decode a private key from 40 little-endian bytes, reducing modulo the
80    /// group order if necessary (matching the Go reference's scalar decoder).
81    #[inline]
82    #[must_use]
83    pub fn from_le_bytes_reduce(bytes: [u8; SCALAR_BYTES]) -> Self {
84        Self(Scalar::from_le_bytes_reduce(bytes))
85    }
86
87    /// Borrow the underlying canonical scalar.
88    #[inline]
89    #[must_use]
90    pub fn as_scalar(&self) -> Scalar {
91        self.0
92    }
93
94    /// Canonical 40-byte little-endian encoding of the private scalar.
95    #[inline]
96    #[must_use]
97    pub fn to_le_bytes(&self) -> [u8; SCALAR_BYTES] {
98        self.0.to_le_bytes()
99    }
100
101    /// Derive the matching public key as `pk = (sk * G).encode()`.
102    ///
103    /// Routes through the constant-time scalar mul so the secret scalar's
104    /// limbs do not leak via timing.
105    #[must_use]
106    pub fn public_key(&self) -> PublicKey {
107        PublicKey(Point::mulgen_ct(self.0).encode())
108    }
109
110    /// Sign a pre-hashed message under the supplied per-signature nonce `k`.
111    ///
112    /// `hashed_msg` is the `Fp5` digest produced by the caller (typically via
113    /// [`crate::signing::hash::hash_to_quintic_extension`] over the message
114    /// field elements, or via [`crate::signing::tx::sign_tx`] which folds
115    /// the body and attribute hashes). `k` MUST be drawn uniformly at random
116    /// from a cryptographic RNG, MUST NOT be zero (a zero nonce trivially
117    /// reveals `sk` from the resulting signature), and MUST NOT repeat across
118    /// distinct signatures under the same key (a repeated nonce reveals `sk`
119    /// from any two signatures sharing it). Matching the Go reference
120    /// `SchnorrSignHashedMessage2`, the caller-contract is enforced by the
121    /// caller - no runtime `k != 0` check is performed inside `sign`.
122    #[inline]
123    #[must_use]
124    pub fn sign(&self, hashed_msg: Fp5, k: Scalar) -> Signature {
125        super::sig::sign(self.0, hashed_msg, k)
126    }
127}
128
129/// A Schnorr public key over the ECgFp5 curve, stored as the canonical
130/// `Fp5` encoding `w = (sk * G).encode()`.
131///
132/// The wire format used by Lighter's L2 protocol is the 40-byte little-endian
133/// representation of this `Fp5` element.
134#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
135pub struct PublicKey(Fp5);
136
137impl PublicKey {
138    /// Wrap an existing `Fp5` encoding as a public key. No curve check is
139    /// performed here; [`Self::verify`] will reject the key if it does not
140    /// decode to a valid group element.
141    #[inline]
142    #[must_use]
143    pub fn from_fp5(w: Fp5) -> Self {
144        Self(w)
145    }
146
147    /// Borrow the underlying `Fp5` encoding.
148    #[inline]
149    #[must_use]
150    pub fn as_fp5(&self) -> Fp5 {
151        self.0
152    }
153
154    /// Decode 40 little-endian bytes into a public key. Returns `None` if any
155    /// 8-byte limb is non-canonical (`>= p`).
156    ///
157    /// Matches the Go reference's `FromCanonicalLittleEndianBytes`, which
158    /// rejects any limb whose `u64` value is `>= p`. Phase E Layer 2 oracle
159    /// tests confirm the closed mainnet signer always emits canonical bytes
160    /// out of `ToLittleEndianBytesF`, so the strict policy round-trips
161    /// without exception. No reducing variant is needed; non-canonical input
162    /// would only ever come from a malformed or adversarial peer.
163    #[inline]
164    #[must_use]
165    pub fn try_from_le_bytes(bytes: [u8; PUBLIC_KEY_BYTES]) -> Option<Self> {
166        Fp5::try_from_le_bytes(bytes).map(Self)
167    }
168
169    /// Canonical 40-byte little-endian encoding of the public key.
170    #[inline]
171    #[must_use]
172    pub fn to_le_bytes(&self) -> [u8; PUBLIC_KEY_BYTES] {
173        self.0.to_le_bytes()
174    }
175
176    /// Verify a signature against this public key for the given pre-hashed
177    /// message. Returns `false` for any decode failure or if the recovered
178    /// challenge differs from the signature's `e` component.
179    #[inline]
180    #[must_use]
181    pub fn verify(&self, hashed_msg: Fp5, sig: &Signature) -> bool {
182        super::sig::verify(self.0, hashed_msg, sig)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use rstest::rstest;
189
190    use super::*;
191    use crate::signing::field::MODULUS;
192
193    #[rstest]
194    fn private_key_debug_redacts_secret_limbs() {
195        let secret_pattern = [0xABu8; SCALAR_BYTES];
196        let sk = PrivateKey::from_le_bytes_reduce(secret_pattern);
197        let formatted = format!("{sk:?}");
198
199        assert_eq!(formatted, format!("PrivateKey({REDACTED})"));
200        assert!(
201            !formatted.contains("ab") && !formatted.contains("AB"),
202            "Debug must not leak secret bytes, was {formatted}",
203        );
204    }
205
206    #[rstest]
207    fn private_key_zeroize_clears_secret_limbs() {
208        let mut private_key = PrivateKey::from_le_bytes_reduce([0xAB; SCALAR_BYTES]);
209
210        private_key.zeroize();
211
212        assert!(private_key.as_scalar().is_zero());
213    }
214
215    #[rstest]
216    fn try_from_le_bytes_accepts_canonical_pubkey() {
217        let pk_bytes = PrivateKey::from_le_bytes_reduce([0x42; SCALAR_BYTES])
218            .public_key()
219            .to_le_bytes();
220        let parsed = PublicKey::try_from_le_bytes(pk_bytes)
221            .expect("canonical pk bytes must round trip through try_from_le_bytes");
222        assert_eq!(parsed.to_le_bytes(), pk_bytes);
223    }
224
225    #[rstest]
226    #[case(0)]
227    #[case(1)]
228    #[case(2)]
229    #[case(3)]
230    #[case(4)]
231    fn try_from_le_bytes_rejects_non_canonical_limb(#[case] limb_index: usize) {
232        let mut bytes = [0u8; PUBLIC_KEY_BYTES];
233        bytes[limb_index * 8..(limb_index + 1) * 8].copy_from_slice(&MODULUS.to_le_bytes());
234        assert!(
235            PublicKey::try_from_le_bytes(bytes).is_none(),
236            "limb {limb_index} == MODULUS must be rejected",
237        );
238
239        bytes[limb_index * 8..(limb_index + 1) * 8].copy_from_slice(&u64::MAX.to_le_bytes());
240        assert!(
241            PublicKey::try_from_le_bytes(bytes).is_none(),
242            "limb {limb_index} == u64::MAX must be rejected",
243        );
244    }
245}