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