Skip to main content

auths_core/pairing/
response.rs

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