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}