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}