Skip to main content

open_agent_id/
crypto.rs

1//! Ed25519 key generation, signing, verification, and encoding utilities.
2
3use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4use base64::Engine;
5use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
6use sha2::{Digest, Sha256};
7
8use crate::error::Error;
9
10/// Generate a new Ed25519 keypair using the OS CSPRNG.
11pub fn generate_keypair() -> (SigningKey, VerifyingKey) {
12    let mut csprng = rand::rngs::OsRng;
13    let signing_key = SigningKey::generate(&mut csprng);
14    let verifying_key = signing_key.verifying_key();
15    (signing_key, verifying_key)
16}
17
18/// Sign `payload` bytes with the given Ed25519 signing key.
19pub fn sign(payload: &[u8], key: &SigningKey) -> Signature {
20    key.sign(payload)
21}
22
23/// Verify an Ed25519 signature against the given payload and verifying key.
24pub fn verify(payload: &[u8], signature: &Signature, key: &VerifyingKey) -> bool {
25    key.verify(payload, signature).is_ok()
26}
27
28/// Compute the SHA-256 hash of `data` and return it as a lowercase hex string.
29pub fn sha256_hex(data: &[u8]) -> String {
30    let mut hasher = Sha256::new();
31    hasher.update(data);
32    hex::encode(hasher.finalize())
33}
34
35/// Encode bytes as base64url (no padding, RFC 4648 Section 5).
36pub fn base64url_encode(data: &[u8]) -> String {
37    URL_SAFE_NO_PAD.encode(data)
38}
39
40/// Decode a base64url (no padding) string to bytes.
41pub fn base64url_decode(data: &str) -> Result<Vec<u8>, Error> {
42    URL_SAFE_NO_PAD
43        .decode(data)
44        .map_err(|e| Error::InvalidKey(format!("base64url decode failed: {e}")))
45}
46
47/// Decode a base64url-encoded Ed25519 public key (32 bytes) into a [`VerifyingKey`].
48pub fn decode_verifying_key(b64: &str) -> Result<VerifyingKey, Error> {
49    let bytes = base64url_decode(b64)?;
50    let arr: [u8; 32] = bytes
51        .try_into()
52        .map_err(|_| Error::InvalidKey("public key must be 32 bytes".into()))?;
53    VerifyingKey::from_bytes(&arr)
54        .map_err(|e| Error::InvalidKey(format!("invalid Ed25519 public key: {e}")))
55}
56
57/// Decode a base64url-encoded Ed25519 private key (32-byte seed) into a [`SigningKey`].
58///
59/// Accepts either 32 bytes (seed only) or 64 bytes (seed + public key, only the
60/// first 32 bytes are used).
61pub fn decode_signing_key(b64: &str) -> Result<SigningKey, Error> {
62    let bytes = base64url_decode(b64)?;
63    if bytes.len() != 32 && bytes.len() != 64 {
64        return Err(Error::InvalidKey(format!(
65            "private key must be 32 or 64 bytes, got {}",
66            bytes.len()
67        )));
68    }
69    let seed: [u8; 32] = bytes[..32]
70        .try_into()
71        .map_err(|_| Error::InvalidKey("invalid private key bytes".into()))?;
72    Ok(SigningKey::from_bytes(&seed))
73}
74
75/// Generate a random 16-byte nonce and return it as a 32-character hex string.
76pub fn generate_nonce() -> String {
77    use rand::Rng;
78    let mut rng = rand::thread_rng();
79    let bytes: [u8; 16] = rng.gen();
80    hex::encode(bytes)
81}
82
83// ---------------------------------------------------------------------------
84// End-to-end encryption (NaCl box: X25519-XSalsa20-Poly1305)
85// ---------------------------------------------------------------------------
86
87use crypto_box::{
88    aead::{Aead, AeadCore, OsRng},
89    PublicKey as BoxPublicKey, SalsaBox, SecretKey as BoxSecretKey,
90};
91use curve25519_dalek::edwards::CompressedEdwardsY;
92
93/// Convert an Ed25519 public key (32 bytes) to an X25519 public key.
94pub fn ed25519_to_x25519_public(ed25519_pub: &[u8; 32]) -> Result<[u8; 32], Error> {
95    let compressed = CompressedEdwardsY::from_slice(ed25519_pub)
96        .map_err(|e| Error::InvalidKey(format!("invalid Ed25519 public key: {e}")))?;
97    let edwards = compressed
98        .decompress()
99        .ok_or_else(|| Error::InvalidKey("failed to decompress Ed25519 point".into()))?;
100    Ok(edwards.to_montgomery().to_bytes())
101}
102
103/// Convert an Ed25519 private key (signing key) to an X25519 private key.
104///
105/// This uses the standard procedure: SHA-512 hash of the seed, take the first
106/// 32 bytes and clamp.
107pub fn ed25519_to_x25519_private(signing_key: &SigningKey) -> [u8; 32] {
108    // Standard conversion: SHA-512(seed), take first 32 bytes, clamp.
109    use sha2::Sha512;
110    let hash = <Sha512 as Digest>::digest(signing_key.as_bytes());
111    let mut key = [0u8; 32];
112    key.copy_from_slice(&hash[..32]);
113    key[0] &= 248;
114    key[31] &= 127;
115    key[31] |= 64;
116    key
117}
118
119/// Encrypt plaintext for a recipient using NaCl box (X25519-XSalsa20-Poly1305).
120///
121/// Returns `[24-byte nonce][ciphertext + 16-byte MAC]`.
122pub fn encrypt_for(
123    plaintext: &[u8],
124    recipient_ed25519_pub: &[u8; 32],
125    sender_signing_key: &SigningKey,
126) -> Result<Vec<u8>, Error> {
127    let sender_x25519 = ed25519_to_x25519_private(sender_signing_key);
128    let recipient_x25519 = ed25519_to_x25519_public(recipient_ed25519_pub)?;
129
130    let sender_secret = BoxSecretKey::from(sender_x25519);
131    let recipient_public = BoxPublicKey::from(recipient_x25519);
132
133    let salsa_box = SalsaBox::new(&recipient_public, &sender_secret);
134    let nonce = SalsaBox::generate_nonce(&mut OsRng);
135    let encrypted = salsa_box
136        .encrypt(&nonce, plaintext)
137        .map_err(|e| Error::Signing(format!("encryption failed: {e}")))?;
138
139    let mut result = Vec::with_capacity(24 + encrypted.len());
140    result.extend_from_slice(&nonce);
141    result.extend_from_slice(&encrypted);
142    Ok(result)
143}
144
145/// Decrypt ciphertext from a sender using NaCl box.
146///
147/// The ciphertext must include the 24-byte nonce prefix.
148pub fn decrypt_from(
149    ciphertext: &[u8],
150    sender_ed25519_pub: &[u8; 32],
151    recipient_signing_key: &SigningKey,
152) -> Result<Vec<u8>, Error> {
153    if ciphertext.len() < 24 {
154        return Err(Error::InvalidKey("ciphertext too short".into()));
155    }
156
157    let recipient_x25519 = ed25519_to_x25519_private(recipient_signing_key);
158    let sender_x25519 = ed25519_to_x25519_public(sender_ed25519_pub)?;
159
160    let recipient_secret = BoxSecretKey::from(recipient_x25519);
161    let sender_public = BoxPublicKey::from(sender_x25519);
162
163    let salsa_box = SalsaBox::new(&sender_public, &recipient_secret);
164
165    let nonce = crypto_box::Nonce::from_slice(&ciphertext[..24]);
166    salsa_box
167        .decrypt(nonce, &ciphertext[24..])
168        .map_err(|e| Error::Verification(format!("decryption failed: {e}")))
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn keypair_sign_verify_roundtrip() {
177        let (signing, verifying) = generate_keypair();
178        let msg = b"hello oaid v2";
179        let sig = sign(msg, &signing);
180        assert!(verify(msg, &sig, &verifying));
181    }
182
183    #[test]
184    fn wrong_key_rejects() {
185        let (signing, _) = generate_keypair();
186        let (_, other_verifying) = generate_keypair();
187        let sig = sign(b"hello", &signing);
188        assert!(!verify(b"hello", &sig, &other_verifying));
189    }
190
191    #[test]
192    fn sha256_empty() {
193        assert_eq!(
194            sha256_hex(b""),
195            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
196        );
197    }
198
199    #[test]
200    fn sha256_json() {
201        assert_eq!(
202            sha256_hex(b"{\"task\":\"search\"}"),
203            "0dfd9a0e52fe94a5e6311a6ef4643304c65636ae7fc316a0334e91c9665370af"
204        );
205    }
206
207    #[test]
208    fn base64url_roundtrip() {
209        let data = b"open agent id v2";
210        let encoded = base64url_encode(data);
211        let decoded = base64url_decode(&encoded).unwrap();
212        assert_eq!(decoded, data);
213    }
214
215    #[test]
216    fn decode_verifying_key_valid() {
217        let (_, vk) = generate_keypair();
218        let b64 = base64url_encode(vk.as_bytes());
219        let decoded = decode_verifying_key(&b64).unwrap();
220        assert_eq!(decoded.as_bytes(), vk.as_bytes());
221    }
222
223    #[test]
224    fn decode_signing_key_32_bytes() {
225        let (sk, _) = generate_keypair();
226        let b64 = base64url_encode(&sk.to_bytes());
227        let decoded = decode_signing_key(&b64).unwrap();
228        assert_eq!(decoded.to_bytes(), sk.to_bytes());
229    }
230
231    #[test]
232    fn nonce_length() {
233        let nonce = generate_nonce();
234        assert_eq!(nonce.len(), 32);
235        assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
236    }
237
238    #[test]
239    fn key_conversion_deterministic() {
240        let (sk, vk) = generate_keypair();
241        let x_pub1 = ed25519_to_x25519_public(vk.as_bytes()).unwrap();
242        let x_pub2 = ed25519_to_x25519_public(vk.as_bytes()).unwrap();
243        assert_eq!(x_pub1, x_pub2);
244        assert_eq!(x_pub1.len(), 32);
245
246        let x_priv1 = ed25519_to_x25519_private(&sk);
247        let x_priv2 = ed25519_to_x25519_private(&sk);
248        assert_eq!(x_priv1, x_priv2);
249    }
250
251    #[test]
252    fn encrypt_decrypt_roundtrip() {
253        let (sender_sk, _sender_vk) = generate_keypair();
254        let (recipient_sk, recipient_vk) = generate_keypair();
255
256        let plaintext = b"hello agent world";
257        let ciphertext =
258            encrypt_for(plaintext, recipient_vk.as_bytes(), &sender_sk).unwrap();
259
260        // nonce (24) + MAC (16) + plaintext
261        assert_eq!(ciphertext.len(), 24 + 16 + plaintext.len());
262
263        let decrypted =
264            decrypt_from(&ciphertext, _sender_vk.as_bytes(), &recipient_sk).unwrap();
265        assert_eq!(decrypted, plaintext);
266    }
267
268    #[test]
269    fn decrypt_wrong_key_fails() {
270        let (sender_sk, sender_vk) = generate_keypair();
271        let (_recipient_sk, recipient_vk) = generate_keypair();
272        let (wrong_sk, _) = generate_keypair();
273
274        let plaintext = b"secret message";
275        let ciphertext =
276            encrypt_for(plaintext, recipient_vk.as_bytes(), &sender_sk).unwrap();
277
278        let result = decrypt_from(&ciphertext, sender_vk.as_bytes(), &wrong_sk);
279        assert!(result.is_err());
280    }
281
282    #[test]
283    fn encrypt_decrypt_empty_message() {
284        let (sender_sk, sender_vk) = generate_keypair();
285        let (recipient_sk, recipient_vk) = generate_keypair();
286
287        let ciphertext = encrypt_for(b"", recipient_vk.as_bytes(), &sender_sk).unwrap();
288        let decrypted =
289            decrypt_from(&ciphertext, sender_vk.as_bytes(), &recipient_sk).unwrap();
290        assert_eq!(decrypted, b"");
291    }
292
293    #[test]
294    fn rfc8032_test_vector_1() {
295        let seed_hex = "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60";
296        let seed_bytes = hex::decode(seed_hex).unwrap();
297        let sk = SigningKey::from_bytes(&seed_bytes.try_into().unwrap());
298        let sig = sign(b"", &sk);
299        let expected = "e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e065224901555fb8821590a33bacc61e39701cf9b46bd25bf5f0595bbe24655141438e7a100b";
300        assert_eq!(hex::encode(sig.to_bytes()), expected);
301    }
302}