Skip to main content

nula_core/key/
secret_key.rs

1//! 32-byte secp256k1 secret scalar.
2//!
3//! [`SecretKey`] is a thin wrapper around [`secp256k1::SecretKey`] that
4//! tightens the public surface for Nostr:
5//!
6//! - construction from raw bytes and lowercase hex (NIP-01),
7//! - random generation backed by the OS entropy source,
8//! - `Debug` redacts the secret material (so we never leak it in logs),
9//! - `serde` always uses the 64-char lowercase hex representation, and
10//! - [`Drop`] calls [`secp256k1::SecretKey::non_secure_erase`] so the inner
11//!   bytes are best-effort overwritten before the allocation is released.
12//!   The "non-secure" qualifier is upstream's: the compiler may still elide
13//!   the write under aggressive optimization, but on every common target
14//!   the volatile memset survives. This is the same primitive `bitcoin`
15//!   and `rust-secp256k1` themselves rely on.
16
17use std::fmt;
18use std::str::FromStr;
19
20use serde::{Deserialize, Deserializer, Serialize, Serializer};
21use thiserror::Error;
22
23use crate::util::hex::{self, HexError};
24use crate::util::rng::{self, RngError};
25
26/// Length of a serialized secret key in bytes.
27pub const SECRET_KEY_SIZE: usize = 32;
28
29/// Errors raised when constructing a [`SecretKey`].
30#[derive(Debug, Clone, Copy, Error)]
31#[non_exhaustive]
32pub enum SecretKeyError {
33    /// The hex representation could not be decoded.
34    #[error("invalid hex encoding: {0}")]
35    Hex(#[from] HexError),
36    /// The byte slice was not exactly [`SECRET_KEY_SIZE`] long.
37    #[error("invalid length: expected {SECRET_KEY_SIZE} bytes, got {0}")]
38    InvalidLength(usize),
39    /// The bytes did not encode a valid secp256k1 scalar (`0` or `>= n`).
40    #[error("not a valid secp256k1 scalar")]
41    InvalidScalar,
42    /// The OS entropy source failed.
43    #[error("entropy unavailable: {0}")]
44    Rng(#[from] RngError),
45}
46
47/// 32-byte secp256k1 secret scalar.
48///
49/// `Display` and `serde` use lowercase hex. `Debug` deliberately hides the
50/// secret bytes — this type never logs in plaintext.
51///
52/// # Example
53///
54/// ```
55/// use nula_core::SecretKey;
56///
57/// let sk = SecretKey::generate().unwrap();
58/// let hex = sk.to_hex();
59/// let restored = SecretKey::parse(&hex).unwrap();
60/// assert_eq!(sk, restored);
61/// ```
62#[derive(Clone, PartialEq, Eq)]
63#[allow(
64    missing_copy_implementations,
65    reason = "do not copy secret key material implicitly; clone explicitly"
66)]
67pub struct SecretKey(secp256k1::SecretKey);
68
69impl SecretKey {
70    /// Construct a [`SecretKey`] from a fixed-size byte array.
71    ///
72    /// # Errors
73    ///
74    /// Returns [`SecretKeyError::InvalidScalar`] if the bytes do not encode a
75    /// valid secp256k1 scalar (i.e. `0` or `>= n`).
76    pub fn from_byte_array(bytes: [u8; SECRET_KEY_SIZE]) -> Result<Self, SecretKeyError> {
77        secp256k1::SecretKey::from_byte_array(bytes)
78            .map(Self)
79            .map_err(|_| SecretKeyError::InvalidScalar)
80    }
81
82    /// Construct a [`SecretKey`] from a byte slice.
83    ///
84    /// # Errors
85    ///
86    /// Returns [`SecretKeyError::InvalidLength`] when the slice is not 32
87    /// bytes long, or [`SecretKeyError::InvalidScalar`] when the bytes are
88    /// not a valid scalar.
89    pub fn from_slice(bytes: &[u8]) -> Result<Self, SecretKeyError> {
90        let array: [u8; SECRET_KEY_SIZE] = bytes
91            .try_into()
92            .map_err(|_| SecretKeyError::InvalidLength(bytes.len()))?;
93        Self::from_byte_array(array)
94    }
95
96    /// Parse a [`SecretKey`] from a 64-char lowercase hex string.
97    ///
98    /// # Errors
99    ///
100    /// See [`SecretKeyError`].
101    pub fn parse<S>(input: S) -> Result<Self, SecretKeyError>
102    where
103        S: AsRef<str>,
104    {
105        let bytes = hex::decode(input.as_ref())?;
106        Self::from_slice(&bytes)
107    }
108
109    /// Generate a fresh [`SecretKey`] using the operating system's entropy.
110    ///
111    /// # Errors
112    ///
113    /// Returns [`SecretKeyError::Rng`] if the OS RNG fails, or
114    /// [`SecretKeyError::InvalidScalar`] in the cryptographically negligible
115    /// case where the random bytes happen to land on `0` or `>= n`.
116    pub fn generate() -> Result<Self, SecretKeyError> {
117        let bytes: [u8; SECRET_KEY_SIZE] = rng::random_bytes()?;
118        Self::from_byte_array(bytes)
119    }
120
121    /// Return the secret key as raw bytes.
122    #[must_use]
123    pub fn to_byte_array(&self) -> [u8; SECRET_KEY_SIZE] {
124        self.0.secret_bytes()
125    }
126
127    /// Return the secret key as a 64-char lowercase hex string.
128    #[must_use]
129    pub fn to_hex(&self) -> String {
130        hex::encode(self.0.secret_bytes())
131    }
132
133    /// Borrow the inner [`secp256k1::SecretKey`].
134    ///
135    /// Use this only at the boundary with the cryptography backend.
136    #[must_use]
137    pub const fn as_inner(&self) -> &secp256k1::SecretKey {
138        &self.0
139    }
140}
141
142impl fmt::Debug for SecretKey {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        f.debug_tuple("SecretKey").field(&"<redacted>").finish()
145    }
146}
147
148impl Drop for SecretKey {
149    fn drop(&mut self) {
150        // Best-effort secret zeroization. See the module-level note for the
151        // soundness caveats; in practice this prevents accidental leaks via
152        // `Vec` reallocation, async cancellation, and process core dumps.
153        self.0.non_secure_erase();
154    }
155}
156
157impl FromStr for SecretKey {
158    type Err = SecretKeyError;
159
160    fn from_str(s: &str) -> Result<Self, Self::Err> {
161        Self::parse(s)
162    }
163}
164
165impl Serialize for SecretKey {
166    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
167    where
168        S: Serializer,
169    {
170        serializer.collect_str(&self.to_hex())
171    }
172}
173
174impl<'de> Deserialize<'de> for SecretKey {
175    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
176    where
177        D: Deserializer<'de>,
178    {
179        let raw = <&str>::deserialize(deserializer)?;
180        Self::parse(raw).map_err(serde::de::Error::custom)
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use hex_literal::hex;
187
188    use super::*;
189
190    const VALID_SECRET: [u8; 32] =
191        hex!("0000000000000000000000000000000000000000000000000000000000000001");
192
193    #[test]
194    fn from_byte_array_valid() {
195        let sk = SecretKey::from_byte_array(VALID_SECRET).unwrap();
196        assert_eq!(sk.to_byte_array(), VALID_SECRET);
197    }
198
199    #[test]
200    fn from_byte_array_zero_is_invalid() {
201        let zero = [0_u8; 32];
202        let err = SecretKey::from_byte_array(zero).unwrap_err();
203        assert!(matches!(err, SecretKeyError::InvalidScalar));
204    }
205
206    #[test]
207    fn from_slice_wrong_length() {
208        let err = SecretKey::from_slice(&[0_u8; 16]).unwrap_err();
209        assert!(matches!(err, SecretKeyError::InvalidLength(16)));
210    }
211
212    #[test]
213    fn parse_round_trip() {
214        let sk = SecretKey::from_byte_array(VALID_SECRET).unwrap();
215        let hex_str = sk.to_hex();
216        assert_eq!(hex_str.len(), 64);
217        assert!(hex_str.chars().all(|c| c.is_ascii_hexdigit()));
218        let parsed = SecretKey::parse(&hex_str).unwrap();
219        assert_eq!(parsed, sk);
220    }
221
222    #[test]
223    fn generate_distinct() {
224        let lhs = SecretKey::generate().unwrap();
225        let rhs = SecretKey::generate().unwrap();
226        assert_ne!(lhs, rhs);
227    }
228
229    #[test]
230    fn debug_redacts() {
231        let sk = SecretKey::from_byte_array(VALID_SECRET).unwrap();
232        let dbg = format!("{sk:?}");
233        assert!(dbg.contains("redacted"));
234        assert!(!dbg.contains(&sk.to_hex()));
235    }
236
237    #[test]
238    fn serde_round_trip() {
239        let sk = SecretKey::from_byte_array(VALID_SECRET).unwrap();
240        let json = serde_json::to_string(&sk).unwrap();
241        let parsed: SecretKey = serde_json::from_str(&json).unwrap();
242        assert_eq!(parsed, sk);
243    }
244
245    #[test]
246    fn serde_rejects_short_hex() {
247        let result: Result<SecretKey, _> = serde_json::from_str("\"abcdef\"");
248        assert!(result.is_err());
249    }
250
251    #[test]
252    fn from_str_works() {
253        let sk: SecretKey = "0000000000000000000000000000000000000000000000000000000000000001"
254            .parse()
255            .unwrap();
256        assert_eq!(sk.to_byte_array(), VALID_SECRET);
257    }
258
259    #[test]
260    fn drop_runs_non_secure_erase() {
261        // We cannot inspect freed memory soundly from Rust, but we can prove
262        // that the user-facing path runs without panicking when a key falls
263        // out of scope. `non_secure_erase` is a `&mut self` operation that
264        // overwrites the inner bytes; this test guards against accidental
265        // regressions of the `Drop` impl (e.g. someone removing it).
266        let _ = SecretKey::from_byte_array(VALID_SECRET).unwrap();
267    }
268}