Skip to main content

auths_pairing_protocol/
response.rs

1use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2use chrono::{DateTime, Utc};
3use rand::rngs::OsRng;
4use ring::signature::{ED25519, Ed25519KeyPair, UnparsedPublicKey};
5use serde::{Deserialize, Serialize};
6use x25519_dalek::{EphemeralSecret, PublicKey};
7use zeroize::Zeroizing;
8
9use auths_crypto::SecureSeed;
10
11use crate::error::ProtocolError;
12use crate::token::PairingToken;
13
14/// A response to a pairing request from the responding device.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct PairingResponse {
17    pub short_code: String,
18    pub device_x25519_pubkey: String,
19    pub device_signing_pubkey: String,
20    pub device_did: String,
21    pub signature: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub device_name: Option<String>,
24}
25
26impl PairingResponse {
27    /// Create a new pairing response (responder side).
28    ///
29    /// Args:
30    /// * `now` - Current time for expiry checking
31    /// * `token` - The pairing token from the initiating device
32    /// * `device_seed` - The responding device's Ed25519 seed
33    /// * `device_pubkey` - The responding device's Ed25519 public key
34    /// * `device_did` - The responding device's DID string
35    /// * `device_name` - Optional friendly name for the device
36    pub fn create(
37        now: DateTime<Utc>,
38        token: &PairingToken,
39        device_seed: &SecureSeed,
40        device_pubkey: &[u8; 32],
41        device_did: String,
42        device_name: Option<String>,
43    ) -> Result<(Self, Zeroizing<[u8; 32]>), ProtocolError> {
44        if token.is_expired(now) {
45            return Err(ProtocolError::Expired);
46        }
47
48        let device_x25519_secret = EphemeralSecret::random_from_rng(OsRng);
49        let device_x25519_public = PublicKey::from(&device_x25519_secret);
50
51        let initiator_x25519_bytes = token.ephemeral_pubkey_bytes()?;
52        let initiator_x25519 = PublicKey::from(initiator_x25519_bytes);
53
54        let shared = device_x25519_secret.diffie_hellman(&initiator_x25519);
55        let shared_secret = Zeroizing::new(*shared.as_bytes());
56
57        let device_signing_pubkey = URL_SAFE_NO_PAD.encode(device_pubkey);
58        let device_x25519_pubkey_str = URL_SAFE_NO_PAD.encode(device_x25519_public.as_bytes());
59
60        let mut message = Vec::new();
61        message.extend_from_slice(token.short_code.as_bytes());
62        message.extend_from_slice(&initiator_x25519_bytes);
63        message.extend_from_slice(device_x25519_public.as_bytes());
64
65        // Sign with Ed25519 via ring directly (no tokio needed)
66        let sig_bytes = sign_ed25519_sync(device_seed, &message)?;
67        let signature = URL_SAFE_NO_PAD.encode(&sig_bytes);
68
69        let response = PairingResponse {
70            short_code: token.short_code.clone(),
71            device_x25519_pubkey: device_x25519_pubkey_str,
72            device_signing_pubkey,
73            device_did,
74            signature,
75            device_name,
76        };
77
78        Ok((response, shared_secret))
79    }
80
81    /// Verify the response's Ed25519 signature.
82    ///
83    /// Args:
84    /// * `now` - Current time for expiry checking
85    /// * `token` - The pairing token to verify against
86    pub fn verify(&self, now: DateTime<Utc>, token: &PairingToken) -> Result<(), ProtocolError> {
87        if token.is_expired(now) {
88            return Err(ProtocolError::Expired);
89        }
90
91        let initiator_x25519_bytes = token.ephemeral_pubkey_bytes()?;
92        let device_x25519_bytes = self.device_x25519_pubkey_bytes()?;
93        let device_signing_bytes = self.device_signing_pubkey_bytes()?;
94        let signature_bytes = URL_SAFE_NO_PAD
95            .decode(&self.signature)
96            .map_err(|_| ProtocolError::InvalidSignature)?;
97
98        let mut message = Vec::new();
99        message.extend_from_slice(token.short_code.as_bytes());
100        message.extend_from_slice(&initiator_x25519_bytes);
101        message.extend_from_slice(&device_x25519_bytes);
102
103        let peer_public_key = UnparsedPublicKey::new(&ED25519, &device_signing_bytes);
104        peer_public_key
105            .verify(&message, &signature_bytes)
106            .map_err(|_| ProtocolError::InvalidSignature)?;
107
108        Ok(())
109    }
110
111    pub fn device_x25519_pubkey_bytes(&self) -> Result<[u8; 32], ProtocolError> {
112        let bytes = URL_SAFE_NO_PAD
113            .decode(&self.device_x25519_pubkey)
114            .map_err(|_| ProtocolError::InvalidSignature)?;
115        bytes.try_into().map_err(|_| {
116            ProtocolError::KeyExchangeFailed("Invalid X25519 pubkey length".to_string())
117        })
118    }
119
120    pub fn device_signing_pubkey_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
121        URL_SAFE_NO_PAD
122            .decode(&self.device_signing_pubkey)
123            .map_err(|_| ProtocolError::InvalidSignature)
124    }
125}
126
127/// Sign a message with Ed25519 using ring directly (sync, no tokio).
128fn sign_ed25519_sync(seed: &SecureSeed, message: &[u8]) -> Result<Vec<u8>, ProtocolError> {
129    let keypair = Ed25519KeyPair::from_seed_unchecked(seed.as_bytes())
130        .map_err(|e| ProtocolError::KeyGenFailed(format!("{e}")))?;
131    Ok(keypair.sign(message).as_ref().to_vec())
132}
133
134/// Generate a fresh Ed25519 keypair using ring directly (sync, no tokio).
135#[cfg(test)]
136fn generate_ed25519_keypair_sync() -> Result<(SecureSeed, [u8; 32]), ProtocolError> {
137    use ring::rand::SystemRandom;
138    use ring::signature::KeyPair;
139
140    let rng = SystemRandom::new();
141    let pkcs8_doc = Ed25519KeyPair::generate_pkcs8(&rng)
142        .map_err(|_| ProtocolError::KeyGenFailed("Key generation failed".to_string()))?;
143    let keypair = Ed25519KeyPair::from_pkcs8(pkcs8_doc.as_ref())
144        .map_err(|e| ProtocolError::KeyGenFailed(format!("{e}")))?;
145
146    let public_key: [u8; 32] = keypair
147        .public_key()
148        .as_ref()
149        .try_into()
150        .map_err(|_| ProtocolError::KeyGenFailed("Public key not 32 bytes".to_string()))?;
151
152    // ring's Ed25519 PKCS#8 v2 places the seed at bytes [16..48]
153    let pkcs8_bytes = pkcs8_doc.as_ref();
154    let seed: [u8; 32] = pkcs8_bytes[16..48]
155        .try_into()
156        .map_err(|_| ProtocolError::KeyGenFailed("Seed extraction failed".to_string()))?;
157
158    Ok((SecureSeed::new(seed), public_key))
159}
160
161#[cfg(test)]
162#[allow(clippy::disallowed_methods)]
163mod tests {
164    use super::*;
165    use crate::token::PairingToken;
166
167    fn make_token() -> crate::token::PairingSession {
168        PairingToken::generate(
169            chrono::Utc::now(),
170            "did:keri:test123".to_string(),
171            "http://localhost:3000".to_string(),
172            vec!["sign_commit".to_string()],
173        )
174        .unwrap()
175    }
176
177    #[test]
178    fn test_create_and_verify_response() {
179        let now = chrono::Utc::now();
180        let session = make_token();
181        let (seed, pubkey) = generate_ed25519_keypair_sync().unwrap();
182
183        let (response, _shared_secret) = PairingResponse::create(
184            now,
185            &session.token,
186            &seed,
187            &pubkey,
188            "did:key:z6MkTest".to_string(),
189            Some("Test Device".to_string()),
190        )
191        .unwrap();
192
193        assert!(response.verify(now, &session.token).is_ok());
194    }
195
196    #[test]
197    fn test_expired_token_rejected() {
198        use chrono::Duration;
199
200        let now = chrono::Utc::now();
201        let session = PairingToken::generate_with_expiry(
202            now,
203            "did:keri:test".to_string(),
204            "http://localhost:3000".to_string(),
205            vec![],
206            Duration::seconds(-1),
207        )
208        .unwrap();
209        let (seed, pubkey) = generate_ed25519_keypair_sync().unwrap();
210
211        let result = PairingResponse::create(
212            now,
213            &session.token,
214            &seed,
215            &pubkey,
216            "did:key:z6MkTest".to_string(),
217            None,
218        );
219        assert!(matches!(result, Err(ProtocolError::Expired)));
220    }
221
222    #[test]
223    fn test_tampered_signature_rejected() {
224        let now = chrono::Utc::now();
225        let session = make_token();
226        let (seed, pubkey) = generate_ed25519_keypair_sync().unwrap();
227
228        let (mut response, _) = PairingResponse::create(
229            now,
230            &session.token,
231            &seed,
232            &pubkey,
233            "did:key:z6MkTest".to_string(),
234            None,
235        )
236        .unwrap();
237
238        let mut sig_bytes = URL_SAFE_NO_PAD.decode(&response.signature).unwrap();
239        sig_bytes[0] ^= 0xFF;
240        response.signature = URL_SAFE_NO_PAD.encode(&sig_bytes);
241
242        let result = response.verify(now, &session.token);
243        assert!(matches!(result, Err(ProtocolError::InvalidSignature)));
244    }
245
246    #[test]
247    fn test_shared_secret_matches() {
248        let now = chrono::Utc::now();
249        let mut session = make_token();
250        let (seed, pubkey) = generate_ed25519_keypair_sync().unwrap();
251
252        let (response, responder_secret) = PairingResponse::create(
253            now,
254            &session.token,
255            &seed,
256            &pubkey,
257            "did:key:z6MkTest".to_string(),
258            None,
259        )
260        .unwrap();
261
262        let device_x25519_bytes = response.device_x25519_pubkey_bytes().unwrap();
263        let initiator_secret = session.complete_exchange(&device_x25519_bytes).unwrap();
264
265        assert_eq!(*initiator_secret, *responder_secret);
266    }
267
268    #[test]
269    fn test_session_consumed_prevents_reuse() {
270        let now = chrono::Utc::now();
271        let mut session = make_token();
272        let (seed, pubkey) = generate_ed25519_keypair_sync().unwrap();
273
274        let (response, _) = PairingResponse::create(
275            now,
276            &session.token,
277            &seed,
278            &pubkey,
279            "did:key:z6MkTest".to_string(),
280            None,
281        )
282        .unwrap();
283
284        let device_x25519_bytes = response.device_x25519_pubkey_bytes().unwrap();
285
286        assert!(session.complete_exchange(&device_x25519_bytes).is_ok());
287
288        let result = session.complete_exchange(&device_x25519_bytes);
289        assert!(matches!(result, Err(ProtocolError::SessionConsumed)));
290    }
291}