Skip to main content

movement_sdk/crypto/
twisted_ed25519.rs

1//! Twisted Ed25519 encryption keys (Ristretto255 scalar).
2//!
3//! Used by Movement confidential assets and adjacent protocols. The private key is a
4//! Ristretto255 scalar `s`; the corresponding public ("encryption") key is `pk = s⁻¹·H`
5//! where `H` is the domain-separated secondary generator
6//! [`H_RISTRETTO_COMPRESSED`] (= TS `HASH_BASE_POINT`, also the on-chain constant).
7//!
8//! This is **not** an Ed25519 signing key. It produces no signatures; it's a key for the
9//! Twisted `ElGamal` encryption scheme used by confidential-assets et al.
10//!
11//! # Security
12//!
13//! The private key zeroizes on drop (`curve25519-dalek` `Scalar` implements `Zeroize`)
14//! and has a redacted `Debug` impl so accidental logging never leaks key bytes.
15
16use crate::error::{MovementError, MovementResult};
17use curve25519_dalek::ristretto::{CompressedRistretto, RistrettoPoint};
18use curve25519_dalek::scalar::Scalar;
19use rand::RngCore;
20use std::fmt;
21use zeroize::Zeroize;
22
23/// Length, in bytes, of a Twisted Ed25519 private key (a Ristretto255 scalar).
24pub const TWISTED_ED25519_PRIVATE_KEY_LENGTH: usize = 32;
25/// Length, in bytes, of a Twisted Ed25519 public key (a compressed Ristretto255 point).
26pub const TWISTED_ED25519_PUBLIC_KEY_LENGTH: usize = 32;
27
28/// Domain string signed by an account's Ed25519 key to derive a deterministic
29/// confidential decryption key.
30pub const DECRYPTION_KEY_DERIVATION_MESSAGE: &[u8] =
31    b"MovementConfidentialAsset::DecryptionKeyDerivation";
32
33/// Compressed encoding of the secondary generator **H** for Twisted `ElGamal` /
34/// Twisted Ed25519. Matches TS `HASH_BASE_POINT` and the on-chain constant.
35pub const H_RISTRETTO_COMPRESSED: [u8; TWISTED_ED25519_PUBLIC_KEY_LENGTH] = [
36    0x8c, 0x92, 0x40, 0xb4, 0x56, 0xa9, 0xe6, 0xdc, 0x65, 0xc3, 0x77, 0xa1, 0x04, 0x8d, 0x74, 0x5f,
37    0x94, 0xa0, 0x8c, 0xdb, 0x7f, 0x44, 0xcb, 0xcd, 0x7b, 0x46, 0xf3, 0x40, 0x48, 0x87, 0x11, 0x34,
38];
39
40/// Decompressed form of [`H_RISTRETTO_COMPRESSED`].
41///
42/// # Panics
43///
44/// Never in practice: [`H_RISTRETTO_COMPRESSED`] is a hard-coded canonical Ristretto255
45/// encoding. The `expect` is a build-time invariant; the `tests::h_matches_compressed_constant`
46/// test pins it.
47pub fn h_ristretto() -> RistrettoPoint {
48    CompressedRistretto(H_RISTRETTO_COMPRESSED)
49        .decompress()
50        .expect("H_RISTRETTO_COMPRESSED is a valid Ristretto encoding")
51}
52
53/// A Twisted Ed25519 private key (Ristretto255 scalar).
54///
55/// The private key is zeroized when dropped to prevent sensitive data from
56/// remaining in memory, and `Debug` is redacted to avoid accidental leaks.
57#[derive(Clone, Zeroize)]
58#[zeroize(drop)]
59pub struct TwistedEd25519PrivateKey {
60    scalar: Scalar,
61}
62
63impl TwistedEd25519PrivateKey {
64    /// Generates a new random private key from `OsRng`.
65    pub fn generate() -> Self {
66        let mut bytes = [0u8; 64];
67        rand::rngs::OsRng.fill_bytes(&mut bytes);
68        let scalar = Scalar::from_bytes_mod_order_wide(&bytes);
69        bytes.zeroize();
70        Self { scalar }
71    }
72
73    /// Creates a private key from raw bytes (32 bytes, little-endian, reduced mod
74    /// the Ristretto255 group order).
75    ///
76    /// # Errors
77    ///
78    /// Returns [`MovementError::InvalidPrivateKey`] if the byte slice length is
79    /// not exactly 32 bytes.
80    pub fn from_bytes(bytes: &[u8]) -> MovementResult<Self> {
81        if bytes.len() != TWISTED_ED25519_PRIVATE_KEY_LENGTH {
82            return Err(MovementError::InvalidPrivateKey(format!(
83                "expected {} bytes, got {}",
84                TWISTED_ED25519_PRIVATE_KEY_LENGTH,
85                bytes.len()
86            )));
87        }
88        let mut buf = [0u8; TWISTED_ED25519_PRIVATE_KEY_LENGTH];
89        buf.copy_from_slice(bytes);
90        let scalar = Scalar::from_bytes_mod_order(buf);
91        // SECURITY: zeroize the temporary buffer that held private key material.
92        buf.zeroize();
93        Ok(Self { scalar })
94    }
95
96    /// Creates a private key from a hex string (with or without `0x` prefix).
97    ///
98    /// # Errors
99    ///
100    /// Returns [`MovementError::Hex`] if the hex string is invalid, or
101    /// [`MovementError::InvalidPrivateKey`] if the decoded length is not 32 bytes.
102    pub fn from_hex(hex_str: &str) -> MovementResult<Self> {
103        let bytes = const_hex::decode(hex_str)?;
104        Self::from_bytes(&bytes)
105    }
106
107    /// Wraps an existing Ristretto255 scalar as a private key.
108    pub fn from_scalar(scalar: Scalar) -> Self {
109        Self { scalar }
110    }
111
112    /// Returns the corresponding encryption (public) key, `pk = s⁻¹·H`.
113    pub fn public_key(&self) -> TwistedEd25519PublicKey {
114        TwistedEd25519PublicKey {
115            point: h_ristretto() * self.scalar.invert(),
116        }
117    }
118
119    /// Borrows the underlying scalar for σ-protocol provers.
120    pub fn as_scalar(&self) -> &Scalar {
121        &self.scalar
122    }
123
124    /// Returns the private key as 32 little-endian bytes.
125    ///
126    /// **Warning**: handle the returned bytes carefully to avoid leaking
127    /// sensitive key material.
128    pub fn to_bytes(&self) -> [u8; TWISTED_ED25519_PRIVATE_KEY_LENGTH] {
129        self.scalar.to_bytes()
130    }
131
132    /// Returns the private key as a hex string (lowercase, `0x`-prefixed).
133    ///
134    /// **Warning**: handle the returned string carefully — it contains private
135    /// key material in plaintext.
136    pub fn to_hex(&self) -> String {
137        const_hex::encode_prefixed(self.to_bytes())
138    }
139}
140
141impl fmt::Debug for TwistedEd25519PrivateKey {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        write!(f, "TwistedEd25519PrivateKey([REDACTED])")
144    }
145}
146
147/// A Twisted Ed25519 public (encryption) key — a Ristretto255 point.
148#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct TwistedEd25519PublicKey {
150    point: RistrettoPoint,
151}
152
153impl TwistedEd25519PublicKey {
154    /// Decodes a public key from a 32-byte compressed Ristretto255 encoding.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`MovementError::InvalidPublicKey`] if the bytes are not a valid
159    /// canonical Ristretto255 encoding.
160    pub fn from_bytes(bytes: &[u8; TWISTED_ED25519_PUBLIC_KEY_LENGTH]) -> MovementResult<Self> {
161        let point = CompressedRistretto(*bytes)
162            .decompress()
163            .ok_or_else(|| MovementError::InvalidPublicKey("invalid Ristretto encoding".into()))?;
164        Ok(Self { point })
165    }
166
167    /// Wraps an existing Ristretto255 point as a public key.
168    pub fn from_point(point: RistrettoPoint) -> Self {
169        Self { point }
170    }
171
172    /// Borrows the underlying Ristretto255 point.
173    pub fn as_point(&self) -> &RistrettoPoint {
174        &self.point
175    }
176
177    /// Returns the public key as 32 compressed Ristretto255 bytes.
178    pub fn to_bytes(&self) -> [u8; TWISTED_ED25519_PUBLIC_KEY_LENGTH] {
179        self.point.compress().to_bytes()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn h_matches_compressed_constant() {
189        assert_eq!(h_ristretto().compress().to_bytes(), H_RISTRETTO_COMPRESSED);
190    }
191
192    #[test]
193    fn debug_is_redacted() {
194        let key = TwistedEd25519PrivateKey::generate();
195        let debug = format!("{key:?}");
196        assert!(
197            debug.contains("REDACTED"),
198            "private-key Debug must be redacted, got: {debug}"
199        );
200        // Bytes must not appear in any rendering.
201        let hex = key.to_hex();
202        let stripped = hex.trim_start_matches("0x");
203        assert!(
204            !debug.contains(stripped),
205            "private-key Debug must not contain key bytes"
206        );
207    }
208
209    #[test]
210    fn from_bytes_length_check() {
211        assert!(TwistedEd25519PrivateKey::from_bytes(&[0u8; 31]).is_err());
212        assert!(TwistedEd25519PrivateKey::from_bytes(&[0u8; 33]).is_err());
213        assert!(TwistedEd25519PrivateKey::from_bytes(&[0u8; 32]).is_ok());
214    }
215
216    #[test]
217    fn roundtrip_bytes() {
218        let key = TwistedEd25519PrivateKey::generate();
219        let bytes = key.to_bytes();
220        let key2 = TwistedEd25519PrivateKey::from_bytes(&bytes).expect("32 bytes");
221        assert_eq!(key.to_bytes(), key2.to_bytes());
222    }
223
224    #[test]
225    fn public_key_decodes_canonical_only() {
226        let pk = TwistedEd25519PrivateKey::generate().public_key();
227        let bytes = pk.to_bytes();
228        assert!(TwistedEd25519PublicKey::from_bytes(&bytes).is_ok());
229        // 0xff..ff is not a canonical Ristretto encoding.
230        assert!(TwistedEd25519PublicKey::from_bytes(&[0xffu8; 32]).is_err());
231    }
232}