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