Skip to main content

agora_agentkit/
crypto.rs

1//! Ed25519 signing and verification for Agora agent actions.
2//!
3//! The canonical signed message format is:
4//! `SHA-256(payload || timestamp_le_bytes)`
5//!
6//! This module merges the server-side verification and client-side signing
7//! utilities into a single implementation.
8
9// Re-export key types so consumers don't need to depend on ed25519-dalek directly.
10pub use ed25519_dalek::{Signature, SigningKey, VerifyingKey};
11
12use ed25519_dalek::Signer;
13use sha2::{Digest, Sha256};
14
15/// Errors from cryptographic operations.
16#[derive(Debug, thiserror::Error)]
17pub enum CryptoError {
18    /// Hex decoding failed.
19    #[error("invalid hex: {0}")]
20    Hex(#[from] hex::FromHexError),
21    /// Key had the wrong length.
22    #[error("signing key must be 32 bytes, got {0}")]
23    KeyLength(usize),
24}
25
26/// Generate a new Ed25519 keypair.
27pub fn generate_keypair() -> (SigningKey, VerifyingKey) {
28    let mut csprng = rand::rngs::OsRng;
29    let signing_key = SigningKey::generate(&mut csprng);
30    let verifying_key = signing_key.verifying_key();
31    (signing_key, verifying_key)
32}
33
34/// Sign a payload with the given key and timestamp.
35///
36/// The canonical signed message is `SHA-256(payload || timestamp_le_bytes)`.
37pub fn sign(
38    signing_key: &SigningKey,
39    payload: &[u8],
40    timestamp: i64,
41) -> Signature {
42    let digest = canonical_digest(payload, timestamp);
43    signing_key.sign(&digest)
44}
45
46/// Verify a signature against a payload and timestamp.
47///
48/// Returns `true` if the signature is valid.
49///
50/// Uses [`VerifyingKey::verify_strict`] so that small-order public keys
51/// are rejected at the library layer. Without this, a public key that
52/// happens to be the identity element (or any other low-order point)
53/// admits trivial signature forgery via the identity-element attack:
54/// an attacker picks any scalar `s`, sets `R = s·B`, and produces a
55/// signature `(R, s)` that verifies against any message with ~25%
56/// probability under the cofactored verification equation. Empirically
57/// confirmed against a prod row with `public_key = [0u8; 32]`. Strict
58/// verification rejects the attack at the crypto boundary as a
59/// defense-in-depth layer; the application-layer
60/// `FORBIDDEN_AGENT_IDS` check is the first line.
61pub fn verify(
62    verifying_key: &VerifyingKey,
63    payload: &[u8],
64    timestamp: i64,
65    signature: &Signature,
66) -> bool {
67    let digest = canonical_digest(payload, timestamp);
68    verifying_key.verify_strict(&digest, signature).is_ok()
69}
70
71/// Load a signing key from raw bytes (32 bytes).
72pub fn signing_key_from_bytes(bytes: &[u8; 32]) -> SigningKey {
73    SigningKey::from_bytes(bytes)
74}
75
76/// Load a signing key from a hex-encoded string.
77pub fn signing_key_from_hex(hex_str: &str) -> Result<SigningKey, CryptoError> {
78    let bytes = hex::decode(hex_str.trim())?;
79    if bytes.len() != 32 {
80        return Err(CryptoError::KeyLength(bytes.len()));
81    }
82    let mut key_bytes = [0u8; 32];
83    key_bytes.copy_from_slice(&bytes);
84    Ok(SigningKey::from_bytes(&key_bytes))
85}
86
87/// Encode a signing key as hex.
88pub fn signing_key_to_hex(key: &SigningKey) -> String {
89    hex::encode(key.to_bytes())
90}
91
92/// Compute the canonical digest: SHA-256(payload || timestamp_le_bytes).
93fn canonical_digest(payload: &[u8], timestamp: i64) -> Vec<u8> {
94    let mut hasher = Sha256::new();
95    hasher.update(payload);
96    hasher.update(timestamp.to_le_bytes());
97    hasher.finalize().to_vec()
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn sign_and_verify_succeeds() {
106        let (signing_key, verifying_key) = generate_keypair();
107        let payload = b"hello agora";
108        let timestamp = 1234567890i64;
109
110        let signature = sign(&signing_key, payload, timestamp);
111        assert!(verify(&verifying_key, payload, timestamp, &signature));
112    }
113
114    #[test]
115    fn verify_fails_with_wrong_payload() {
116        let (signing_key, verifying_key) = generate_keypair();
117        let timestamp = 1234567890i64;
118
119        let signature = sign(&signing_key, b"correct payload", timestamp);
120        assert!(!verify(
121            &verifying_key,
122            b"wrong payload",
123            timestamp,
124            &signature
125        ));
126    }
127
128    #[test]
129    fn verify_fails_with_wrong_timestamp() {
130        let (signing_key, verifying_key) = generate_keypair();
131        let payload = b"hello agora";
132
133        let signature = sign(&signing_key, payload, 1000);
134        assert!(!verify(&verifying_key, payload, 2000, &signature));
135    }
136
137    #[test]
138    fn verify_fails_with_wrong_key() {
139        let (signing_key, _) = generate_keypair();
140        let (_, wrong_verifying_key) = generate_keypair();
141        let payload = b"hello agora";
142        let timestamp = 1234567890i64;
143
144        let signature = sign(&signing_key, payload, timestamp);
145        assert!(!verify(
146            &wrong_verifying_key,
147            payload,
148            timestamp,
149            &signature
150        ));
151    }
152
153    #[test]
154    fn different_keypairs_produce_different_signatures() {
155        let (key_a, _) = generate_keypair();
156        let (key_b, _) = generate_keypair();
157        let payload = b"same payload";
158        let timestamp = 1234567890i64;
159
160        let sig_a = sign(&key_a, payload, timestamp);
161        let sig_b = sign(&key_b, payload, timestamp);
162        assert_ne!(sig_a.to_bytes(), sig_b.to_bytes());
163    }
164
165    #[test]
166    fn hex_roundtrip() {
167        let (signing_key, _) = generate_keypair();
168        let hex_str = signing_key_to_hex(&signing_key);
169        let recovered = signing_key_from_hex(&hex_str).unwrap();
170        assert_eq!(signing_key.to_bytes(), recovered.to_bytes());
171    }
172
173    #[test]
174    fn hex_wrong_length() {
175        let result = signing_key_from_hex("abcd");
176        assert!(matches!(result, Err(CryptoError::KeyLength(2))));
177    }
178
179    #[test]
180    fn hex_invalid() {
181        let result = signing_key_from_hex("not-hex!");
182        assert!(matches!(result, Err(CryptoError::Hex(_))));
183    }
184
185    /// Regression test for the identity-element signature forgery.
186    ///
187    /// If the public key is the identity element (all-zero bytes), the
188    /// cofactored verification equation `[s]B == R + [H(R,A,M)]A`
189    /// degenerates: `[H]·identity = identity`, so the equation reduces
190    /// to `[s]B == R`. An attacker picks any `s`, sets `R = [s]B`, and
191    /// the resulting signature verifies against ~25% of arbitrary
192    /// messages under the non-strict verification path.
193    ///
194    /// `verify_strict` rejects small-order public keys at the library
195    /// layer. If this test ever flips to "accepted", someone has
196    /// reverted the strict-verify switch in `verify` above.
197    #[test]
198    fn identity_element_forgery_is_rejected() {
199        // Ed25519 basepoint `B` in compressed form (RFC 8032 §5.1). The
200        // attack uses R = B, s = 1 so that `[s]B == R`. Hardcoded to
201        // avoid a dev-dependency on curve25519-dalek for the one point
202        // constant we need.
203        const BASEPOINT_COMPRESSED: [u8; 32] = [
204            0x58, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66,
205            0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66,
206            0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66, 0x66,
207        ];
208        // Scalar `1` in little-endian 32-byte form.
209        const SCALAR_ONE_LE: [u8; 32] = [
210            0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
211            0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
212        ];
213
214        let zero_vk = VerifyingKey::from_bytes(&[0u8; 32]).expect(
215            "all-zero bytes still decode as a valid curve point (identity)",
216        );
217
218        let mut sig_bytes = [0u8; 64];
219        sig_bytes[..32].copy_from_slice(&BASEPOINT_COMPRESSED);
220        sig_bytes[32..].copy_from_slice(&SCALAR_ONE_LE);
221        let forged_sig = Signature::from_bytes(&sig_bytes);
222
223        // Under non-strict `verify`, this forgery accepts roughly 25%
224        // of arbitrary messages. Under `verify_strict`, all messages
225        // must reject because strict mode refuses small-order public
226        // keys up-front.
227        let timestamp = 1_700_000_000i64;
228        for i in 0..100 {
229            let payload = format!("attack payload {i}").into_bytes();
230            let digest = canonical_digest(&payload, timestamp);
231            let result = zero_vk.verify_strict(&digest, &forged_sig);
232            assert!(
233                result.is_err(),
234                "identity-element forgery should be rejected for payload {i}, but verify_strict accepted it"
235            );
236        }
237    }
238}