Skip to main content

nexus_sdk/signed_http/
keys.rs

1//! Ed25519 key utilities for signed HTTP.
2//!
3//! Signed HTTP (Leader <=> Tool) uses Ed25519 message signing keys at the
4//! application layer. These keys are not Sui transaction keys:
5//! - Sui tx keys authorize on-chain transactions.
6//! - Signed HTTP keys authorize off-chain HTTP messages.
7//!
8//! Keeping these roles separate is strongly recommended. A compromise of a tool
9//! container/config should not automatically grant on-chain spending power.
10//!
11//! ## Accepted encodings (private key)
12//! This module accepts common encodings for a 32-byte Ed25519 secret key:
13//! - `hex` (64 hex chars, optional `0x` prefix)
14//! - `base64` / `base64url` (with or without padding) for 32 raw bytes
15//! - Sui keytool format: `base64(0x00 || sk32)` (33 bytes) where `0x00` is the
16//!   Ed25519 scheme flag used by Sui.
17//!
18//! ## Example: generate, print, and parse
19//! ```
20//! use nexus_sdk::signed_http::keys::*;
21//!
22//! let keypair = Ed25519Keypair::generate();
23//! let sk_hex = keypair.private_key_hex();
24//! let parsed = Ed25519Keypair::from_private_key_hex_or_base64(&sk_hex).unwrap();
25//! assert_eq!(parsed.public_key_bytes(), keypair.public_key_bytes());
26//! ```
27
28use {
29    base64::{engine::general_purpose, Engine as _},
30    ed25519_dalek::SigningKey,
31    rand::RngCore as _,
32    thiserror::Error,
33};
34
35/// Ed25519 signing key material used for message signing.
36///
37/// This is intentionally minimal and does not include any Sui-specific key types.
38#[derive(Clone)]
39pub struct Ed25519Keypair {
40    signing: SigningKey,
41}
42
43impl Ed25519Keypair {
44    /// Generate a new random Ed25519 signing key.
45    pub fn generate() -> Self {
46        let mut sk = [0u8; 32];
47        rand::rngs::OsRng.fill_bytes(&mut sk);
48        Self {
49            signing: SigningKey::from_bytes(&sk),
50        }
51    }
52
53    /// Construct from an existing `ed25519_dalek::SigningKey`.
54    pub fn from_signing_key(signing: SigningKey) -> Self {
55        Self { signing }
56    }
57
58    /// Parse a private key from hex/base64/base64url formats (see module docs).
59    pub fn from_private_key_hex_or_base64(raw: &str) -> Result<Self, KeyParseError> {
60        parse_ed25519_signing_key(raw).map(Self::from_signing_key)
61    }
62
63    /// Borrow the underlying Ed25519 signing key.
64    pub fn signing_key(&self) -> &SigningKey {
65        &self.signing
66    }
67
68    /// Return the raw 32-byte secret key bytes.
69    pub fn private_key_bytes(&self) -> [u8; 32] {
70        self.signing.to_bytes()
71    }
72
73    /// Return the raw 32-byte public key bytes.
74    pub fn public_key_bytes(&self) -> [u8; 32] {
75        self.signing.verifying_key().to_bytes()
76    }
77
78    /// Hex-encode the 32-byte secret key.
79    pub fn private_key_hex(&self) -> String {
80        hex::encode(self.private_key_bytes())
81    }
82
83    /// Hex-encode the 32-byte public key.
84    pub fn public_key_hex(&self) -> String {
85        hex::encode(self.public_key_bytes())
86    }
87}
88
89/// Key parsing errors for Ed25519 message-signing keys.
90#[derive(Debug, Error)]
91pub enum KeyParseError {
92    #[error("invalid hex private key: {0}")]
93    InvalidHex(String),
94    #[error("invalid base64/base64url private key: {0}")]
95    InvalidBase64(String),
96    #[error("unsupported key scheme flag 0x{flag:02x} (expected 0x00 for ed25519)")]
97    UnsupportedSuiKeySchemeFlag { flag: u8 },
98    #[error(
99        "invalid private key length {len}, expected 32 bytes (raw ed25519) or 33 bytes (0x00 + key)"
100    )]
101    InvalidLength { len: usize },
102}
103
104/// Parse an Ed25519 message-signing key from hex/base64/base64url.
105///
106/// See module docs for accepted formats.
107pub fn parse_ed25519_signing_key(raw: &str) -> Result<SigningKey, KeyParseError> {
108    let raw = raw.trim();
109    let raw_no_0x = raw.strip_prefix("0x").unwrap_or(raw);
110
111    // Try hex first when the input looks like hex. This avoids accidentally
112    // interpreting arbitrary base64 as hex.
113    let looks_like_hex = raw.starts_with("0x")
114        || ((raw_no_0x.len() == 64 || raw_no_0x.len() == 66)
115            && raw_no_0x.chars().all(|c| c.is_ascii_hexdigit()));
116
117    if looks_like_hex {
118        let bytes = hex::decode(raw_no_0x).map_err(|e| KeyParseError::InvalidHex(e.to_string()))?;
119        return match bytes.len() {
120            32 => Ok(SigningKey::from_bytes(
121                &<[u8; 32]>::try_from(bytes.as_slice()).expect("length checked"),
122            )),
123            33 => signing_key_from_sui_flagged_bytes(
124                &<[u8; 33]>::try_from(bytes.as_slice()).expect("length checked"),
125            ),
126            len => Err(KeyParseError::InvalidLength { len }),
127        };
128    }
129
130    // Try base64 / base64url (with/without padding).
131    let try_b64 = |engine: &general_purpose::GeneralPurpose| -> Option<Vec<u8>> {
132        engine.decode(raw.as_bytes()).ok()
133    };
134
135    let bytes = try_b64(&general_purpose::STANDARD)
136        .or_else(|| try_b64(&general_purpose::STANDARD_NO_PAD))
137        .or_else(|| try_b64(&general_purpose::URL_SAFE))
138        .or_else(|| try_b64(&general_purpose::URL_SAFE_NO_PAD))
139        .ok_or_else(|| {
140            KeyParseError::InvalidBase64("expected base64/base64url data".to_string())
141        })?;
142
143    match bytes.len() {
144        32 => Ok(SigningKey::from_bytes(
145            &<[u8; 32]>::try_from(bytes.as_slice()).expect("length checked"),
146        )),
147        33 => signing_key_from_sui_flagged_bytes(
148            &<[u8; 33]>::try_from(bytes.as_slice()).expect("length checked"),
149        ),
150        len => Err(KeyParseError::InvalidLength { len }),
151    }
152}
153
154fn signing_key_from_sui_flagged_bytes(bytes: &[u8; 33]) -> Result<SigningKey, KeyParseError> {
155    const ED25519_FLAG: u8 = 0x00;
156    if bytes[0] != ED25519_FLAG {
157        return Err(KeyParseError::UnsupportedSuiKeySchemeFlag { flag: bytes[0] });
158    }
159    let mut sk = [0u8; 32];
160    sk.copy_from_slice(&bytes[1..]);
161    Ok(SigningKey::from_bytes(&sk))
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    fn make_key_bytes(byte: u8) -> [u8; 32] {
169        [byte; 32]
170    }
171
172    #[test]
173    fn parse_hex_roundtrip() {
174        let keypair = Ed25519Keypair::generate();
175        let hex = keypair.private_key_hex();
176        let parsed = Ed25519Keypair::from_private_key_hex_or_base64(&hex).unwrap();
177        assert_eq!(parsed.public_key_bytes(), keypair.public_key_bytes());
178    }
179
180    #[test]
181    fn parse_base64_roundtrip() {
182        let bytes = make_key_bytes(7);
183        let b64 = general_purpose::STANDARD.encode(bytes);
184        let parsed = Ed25519Keypair::from_private_key_hex_or_base64(&b64).unwrap();
185        assert_eq!(parsed.private_key_bytes(), bytes);
186    }
187
188    #[test]
189    fn parse_sui_keytool_base64_format() {
190        let bytes = make_key_bytes(9);
191        let mut flagged = [0u8; 33];
192        flagged[0] = 0x00;
193        flagged[1..].copy_from_slice(&bytes);
194        let b64 = general_purpose::STANDARD.encode(flagged);
195        let parsed = Ed25519Keypair::from_private_key_hex_or_base64(&b64).unwrap();
196        assert_eq!(parsed.private_key_bytes(), bytes);
197    }
198
199    #[test]
200    fn parse_invalid_hex_reports_error() {
201        assert!(matches!(
202            Ed25519Keypair::from_private_key_hex_or_base64("0xzzzz"),
203            Err(KeyParseError::InvalidHex(_))
204        ));
205    }
206
207    #[test]
208    fn parse_invalid_length_reports_error() {
209        let bytes = vec![0u8; 31];
210        let b64 = general_purpose::STANDARD.encode(bytes);
211        assert!(matches!(
212            Ed25519Keypair::from_private_key_hex_or_base64(&b64),
213            Err(KeyParseError::InvalidLength { len: 31 })
214        ));
215    }
216
217    #[test]
218    fn parse_unsupported_sui_flag_reports_error() {
219        let mut flagged = [0u8; 33];
220        flagged[0] = 0x01;
221        let b64 = general_purpose::STANDARD.encode(flagged);
222        assert!(matches!(
223            Ed25519Keypair::from_private_key_hex_or_base64(&b64),
224            Err(KeyParseError::UnsupportedSuiKeySchemeFlag { flag: 0x01 })
225        ));
226    }
227}