movement_sdk/crypto/
twisted_ed25519.rs1use 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
23pub const TWISTED_ED25519_PRIVATE_KEY_LENGTH: usize = 32;
25pub const TWISTED_ED25519_PUBLIC_KEY_LENGTH: usize = 32;
27
28pub const DECRYPTION_KEY_DERIVATION_MESSAGE: &[u8] =
31 b"MovementConfidentialAsset::DecryptionKeyDerivation";
32
33pub 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
40pub fn h_ristretto() -> RistrettoPoint {
48 CompressedRistretto(H_RISTRETTO_COMPRESSED)
49 .decompress()
50 .expect("H_RISTRETTO_COMPRESSED is a valid Ristretto encoding")
51}
52
53#[derive(Clone, Zeroize)]
58#[zeroize(drop)]
59pub struct TwistedEd25519PrivateKey {
60 scalar: Scalar,
61}
62
63impl TwistedEd25519PrivateKey {
64 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 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 buf.zeroize();
93 Ok(Self { scalar })
94 }
95
96 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 pub fn from_scalar(scalar: Scalar) -> Self {
109 Self { scalar }
110 }
111
112 pub fn public_key(&self) -> TwistedEd25519PublicKey {
114 TwistedEd25519PublicKey {
115 point: h_ristretto() * self.scalar.invert(),
116 }
117 }
118
119 pub fn as_scalar(&self) -> &Scalar {
121 &self.scalar
122 }
123
124 pub fn to_bytes(&self) -> [u8; TWISTED_ED25519_PRIVATE_KEY_LENGTH] {
129 self.scalar.to_bytes()
130 }
131
132 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#[derive(Clone, Debug, PartialEq, Eq)]
149pub struct TwistedEd25519PublicKey {
150 point: RistrettoPoint,
151}
152
153impl TwistedEd25519PublicKey {
154 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 pub fn from_point(point: RistrettoPoint) -> Self {
169 Self { point }
170 }
171
172 pub fn as_point(&self) -> &RistrettoPoint {
174 &self.point
175 }
176
177 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 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 assert!(TwistedEd25519PublicKey::from_bytes(&[0xffu8; 32]).is_err());
231 }
232}