Skip to main content

auths_pairing_protocol/
response.rs

1use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
2use chrono::{DateTime, Utc};
3use p256::elliptic_curve::rand_core::OsRng;
4use p256::elliptic_curve::sec1::ToEncodedPoint;
5use serde::{Deserialize, Serialize};
6use zeroize::Zeroizing;
7
8use auths_crypto::{CurveType, TypedSeed};
9use auths_keri::KeriPublicKey;
10
11use crate::error::ProtocolError;
12use crate::token::PairingToken;
13
14/// A response to a pairing request from the responding device.
15///
16/// The `curve` field carries the device's signing curve in-band, so verifiers
17/// never infer curve from pubkey byte length (a silent-correctness hazard —
18/// see `docs/architecture/cryptography.md` → Wire-format Curve Tagging). The
19/// serialized value is `"ed25519"` or `"p256"`.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct PairingResponse {
22    pub short_code: String,
23    pub device_ephemeral_pubkey: String,
24    pub device_signing_pubkey: String,
25    /// Curve tag for `device_signing_pubkey` / `signature`. Absent → defaults to `P256`
26    /// per the approved Wire-format Curve Tagging rule.
27    #[serde(default)]
28    pub curve: CurveTag,
29    pub device_did: String,
30    pub signature: String,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub device_name: Option<String>,
33}
34
35/// Wire-format curve tag for the pairing response.
36///
37/// Serializes as lowercase `"ed25519"` / `"p256"`. Defaults to `P256` per the
38/// workspace-wide curve default.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[serde(rename_all = "lowercase")]
42pub enum CurveTag {
43    Ed25519,
44    #[default]
45    P256,
46}
47
48impl From<CurveTag> for CurveType {
49    fn from(tag: CurveTag) -> Self {
50        match tag {
51            CurveTag::Ed25519 => CurveType::Ed25519,
52            CurveTag::P256 => CurveType::P256,
53        }
54    }
55}
56
57impl From<CurveType> for CurveTag {
58    fn from(curve: CurveType) -> Self {
59        match curve {
60            CurveType::Ed25519 => CurveTag::Ed25519,
61            CurveType::P256 => CurveTag::P256,
62        }
63    }
64}
65
66impl PairingResponse {
67    /// Create a new pairing response (responder side).
68    ///
69    /// The device's curve flows through the typed seed — no byte-length guessing.
70    /// The emitted `curve` field records the signer's curve so verifiers read it
71    /// off the wire instead of inferring from pubkey length.
72    ///
73    /// Args:
74    /// * `now` - Current time for expiry checking
75    /// * `token` - The pairing token from the initiating device
76    /// * `device_seed` - Typed signing seed (curve carried in-band)
77    /// * `device_pubkey` - The responding device's public key (length matches curve)
78    /// * `device_did` - The responding device's DID string
79    /// * `device_name` - Optional friendly name for the device
80    pub fn create(
81        now: DateTime<Utc>,
82        token: &PairingToken,
83        device_seed: &TypedSeed,
84        device_pubkey: &[u8],
85        device_did: String,
86        device_name: Option<String>,
87    ) -> Result<(Self, Zeroizing<[u8; 32]>), ProtocolError> {
88        if token.is_expired(now) {
89            return Err(ProtocolError::Expired);
90        }
91
92        let expected_len = device_seed.curve().public_key_len();
93        if device_pubkey.len() != expected_len {
94            return Err(ProtocolError::KeyExchangeFailed(format!(
95                "device_pubkey length {} does not match {} (expected {} bytes)",
96                device_pubkey.len(),
97                device_seed.curve(),
98                expected_len,
99            )));
100        }
101
102        let device_ecdh_secret = p256::ecdh::EphemeralSecret::random(&mut OsRng);
103        let device_ecdh_public = device_ecdh_secret.public_key();
104
105        let initiator_ecdh_bytes = token.ephemeral_pubkey_bytes()?;
106        let initiator_pk =
107            p256::PublicKey::from_sec1_bytes(&initiator_ecdh_bytes).map_err(|_| {
108                ProtocolError::KeyExchangeFailed(
109                    "Invalid initiator P-256 ephemeral pubkey (SEC1 decode failed)".to_string(),
110                )
111            })?;
112
113        let shared = device_ecdh_secret.diffie_hellman(&initiator_pk);
114        let shared_bytes: [u8; 32] =
115            shared
116                .raw_secret_bytes()
117                .as_slice()
118                .try_into()
119                .map_err(|_| {
120                    ProtocolError::KeyExchangeFailed("Shared secret not 32 bytes".to_string())
121                })?;
122        let shared_secret = Zeroizing::new(shared_bytes);
123
124        let device_signing_pubkey = URL_SAFE_NO_PAD.encode(device_pubkey);
125        let device_ecdh_pubkey_bytes = device_ecdh_public.to_encoded_point(true);
126        let device_ephemeral_pubkey_str =
127            URL_SAFE_NO_PAD.encode(device_ecdh_pubkey_bytes.as_bytes());
128
129        let mut message = Vec::new();
130        message.extend_from_slice(token.session_id.as_bytes());
131        message.extend_from_slice(token.short_code.as_bytes());
132        message.extend_from_slice(&initiator_ecdh_bytes);
133        message.extend_from_slice(device_ecdh_pubkey_bytes.as_bytes());
134
135        let sig_bytes = typed_sign_sync(device_seed, &message)?;
136        let signature = URL_SAFE_NO_PAD.encode(&sig_bytes);
137
138        let response = PairingResponse {
139            short_code: token.short_code.clone(),
140            device_ephemeral_pubkey: device_ephemeral_pubkey_str,
141            device_signing_pubkey,
142            curve: device_seed.curve().into(),
143            device_did,
144            signature,
145            device_name,
146        };
147
148        Ok((response, shared_secret))
149    }
150
151    /// Verify the response's signature using the curve tag carried on the wire.
152    ///
153    /// Curve dispatch reads `self.curve` directly — never inferred from pubkey
154    /// byte length. See `docs/architecture/cryptography.md` → Wire-format Curve
155    /// Tagging.
156    pub fn verify(&self, now: DateTime<Utc>, token: &PairingToken) -> Result<(), ProtocolError> {
157        if token.is_expired(now) {
158            return Err(ProtocolError::Expired);
159        }
160
161        let initiator_ecdh_bytes = token.ephemeral_pubkey_bytes()?;
162        let device_ecdh_bytes = self.device_ephemeral_pubkey_bytes()?;
163        let device_signing_bytes = self.device_signing_pubkey_bytes()?;
164        let signature_bytes = URL_SAFE_NO_PAD
165            .decode(&self.signature)
166            .map_err(|_| ProtocolError::InvalidSignature)?;
167
168        let mut message = Vec::new();
169        message.extend_from_slice(token.session_id.as_bytes());
170        message.extend_from_slice(token.short_code.as_bytes());
171        message.extend_from_slice(&initiator_ecdh_bytes);
172        message.extend_from_slice(&device_ecdh_bytes);
173
174        let key = build_keri_public_key(self.curve.into(), &device_signing_bytes)?;
175        key.verify_signature(&message, &signature_bytes)
176            .map_err(|_| ProtocolError::InvalidSignature)
177    }
178
179    /// Decode the device's ephemeral P-256 ECDH public key from base64url.
180    pub fn device_ephemeral_pubkey_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
181        URL_SAFE_NO_PAD
182            .decode(&self.device_ephemeral_pubkey)
183            .map_err(|_| ProtocolError::InvalidSignature)
184    }
185
186    pub fn device_signing_pubkey_bytes(&self) -> Result<Vec<u8>, ProtocolError> {
187        URL_SAFE_NO_PAD
188            .decode(&self.device_signing_pubkey)
189            .map_err(|_| ProtocolError::InvalidSignature)
190    }
191}
192
193/// Build a typed `KeriPublicKey` from a curve tag + raw bytes. Rejects
194/// length/curve mismatches — this is the curve-aware replacement for
195/// `match bytes.len() { 32 => …, 33 => … }` at the pairing wire boundary.
196fn build_keri_public_key(curve: CurveType, bytes: &[u8]) -> Result<KeriPublicKey, ProtocolError> {
197    match curve {
198        CurveType::Ed25519 => {
199            let arr: [u8; 32] = bytes.try_into().map_err(|_| {
200                ProtocolError::KeyExchangeFailed(format!(
201                    "Ed25519 pubkey must be 32 bytes, got {}",
202                    bytes.len()
203                ))
204            })?;
205            KeriPublicKey::ed25519(&arr).map_err(|e| {
206                ProtocolError::KeyExchangeFailed(format!("Ed25519 pubkey invalid: {e}"))
207            })
208        }
209        CurveType::P256 => {
210            let arr: [u8; 33] = bytes.try_into().map_err(|_| {
211                ProtocolError::KeyExchangeFailed(format!(
212                    "P-256 compressed pubkey must be 33 bytes, got {}",
213                    bytes.len()
214                ))
215            })?;
216            Ok(KeriPublicKey::P256 {
217                key: arr,
218                transferable: true,
219            })
220        }
221    }
222}
223
224/// Sign a message with a typed device seed (sync, no tokio).
225///
226/// Replaces the earlier `sign_ed25519_sync`: the curve travels with the
227/// `TypedSeed`, so the pairing response path is curve-agnostic end-to-end.
228fn typed_sign_sync(seed: &TypedSeed, message: &[u8]) -> Result<Vec<u8>, ProtocolError> {
229    auths_crypto::typed_sign(seed, message).map_err(|e| ProtocolError::KeyGenFailed(format!("{e}")))
230}
231
232/// Generate a fresh curve-defaulted (P-256) keypair for tests.
233///
234/// Tests that specifically exercise the Ed25519 branch construct their own
235/// `TypedSeed::Ed25519` explicitly.
236#[cfg(test)]
237fn generate_test_keypair() -> Result<(TypedSeed, Vec<u8>), ProtocolError> {
238    use p256::ecdsa::SigningKey;
239    use p256::elliptic_curve::rand_core::OsRng as P256Rng;
240    use p256::pkcs8::EncodePrivateKey;
241
242    let sk = SigningKey::random(&mut P256Rng);
243    #[allow(clippy::disallowed_methods)] // test-only keygen
244    let pkcs8 = sk
245        .to_pkcs8_der()
246        .map_err(|e| ProtocolError::KeyGenFailed(format!("{e}")))?;
247    let parsed = auths_crypto::parse_key_material(pkcs8.as_bytes())
248        .map_err(|e| ProtocolError::KeyGenFailed(format!("{e}")))?;
249    Ok((parsed.seed, parsed.public_key))
250}
251
252#[cfg(test)]
253#[allow(clippy::disallowed_methods)]
254mod tests {
255    use super::*;
256    use crate::token::PairingToken;
257    use auths_keri::Capability;
258
259    fn make_token() -> crate::token::PairingSession {
260        PairingToken::generate(
261            chrono::Utc::now(),
262            "did:keri:test123".to_string(),
263            "http://localhost:3000".to_string(),
264            vec![Capability::sign_commit()],
265        )
266        .unwrap()
267    }
268
269    #[test]
270    fn test_create_and_verify_response_p256() {
271        let now = chrono::Utc::now();
272        let session = make_token();
273        let (seed, pubkey) = generate_test_keypair().unwrap();
274
275        let (response, _shared_secret) = PairingResponse::create(
276            now,
277            &session.token,
278            &seed,
279            &pubkey,
280            "did:key:zDnaTest".to_string(),
281            Some("Test Device".to_string()),
282        )
283        .unwrap();
284
285        assert_eq!(response.curve, CurveTag::P256);
286        assert!(response.verify(now, &session.token).is_ok());
287    }
288
289    #[test]
290    fn test_create_and_verify_response_ed25519() {
291        use ring::rand::SystemRandom;
292        use ring::signature::Ed25519KeyPair;
293
294        let now = chrono::Utc::now();
295        let session = make_token();
296        let rng = SystemRandom::new();
297        let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).unwrap();
298        let parsed = auths_crypto::parse_key_material(pkcs8.as_ref()).unwrap();
299
300        let (response, _shared_secret) = PairingResponse::create(
301            now,
302            &session.token,
303            &parsed.seed,
304            &parsed.public_key,
305            "did:key:z6MkTest".to_string(),
306            Some("Test Device".to_string()),
307        )
308        .unwrap();
309
310        assert_eq!(response.curve, CurveTag::Ed25519);
311        assert!(response.verify(now, &session.token).is_ok());
312    }
313
314    #[test]
315    fn test_expired_token_rejected() {
316        use chrono::Duration;
317
318        let now = chrono::Utc::now();
319        let session = PairingToken::generate_with_expiry(
320            now,
321            "did:keri:test".to_string(),
322            "http://localhost:3000".to_string(),
323            vec![],
324            Duration::seconds(-1),
325        )
326        .unwrap();
327        let (seed, pubkey) = generate_test_keypair().unwrap();
328
329        let result = PairingResponse::create(
330            now,
331            &session.token,
332            &seed,
333            &pubkey,
334            "did:key:zDnaTest".to_string(),
335            None,
336        );
337        assert!(matches!(result, Err(ProtocolError::Expired)));
338    }
339
340    #[test]
341    fn test_tampered_signature_rejected() {
342        let now = chrono::Utc::now();
343        let session = make_token();
344        let (seed, pubkey) = generate_test_keypair().unwrap();
345
346        let (mut response, _) = PairingResponse::create(
347            now,
348            &session.token,
349            &seed,
350            &pubkey,
351            "did:key:zDnaTest".to_string(),
352            None,
353        )
354        .unwrap();
355
356        let mut sig_bytes = URL_SAFE_NO_PAD.decode(&response.signature).unwrap();
357        sig_bytes[0] ^= 0xFF;
358        response.signature = URL_SAFE_NO_PAD.encode(&sig_bytes);
359
360        let result = response.verify(now, &session.token);
361        assert!(matches!(result, Err(ProtocolError::InvalidSignature)));
362    }
363
364    #[test]
365    fn test_curve_length_mismatch_rejected() {
366        // A P-256 seed paired with a 32-byte pubkey is a length/curve mismatch and
367        // must fail at emission time — the curve tag travels with the seed, so the
368        // check is local to `create()` and doesn't depend on wire parsing.
369        let now = chrono::Utc::now();
370        let session = make_token();
371        let (seed, _good_pubkey) = generate_test_keypair().unwrap();
372        let wrong_len_pubkey = vec![0u8; 32];
373
374        let result = PairingResponse::create(
375            now,
376            &session.token,
377            &seed,
378            &wrong_len_pubkey,
379            "did:key:zDnaTest".to_string(),
380            None,
381        );
382        assert!(matches!(result, Err(ProtocolError::KeyExchangeFailed(_))));
383    }
384
385    #[test]
386    fn test_shared_secret_matches() {
387        let now = chrono::Utc::now();
388        let mut session = make_token();
389        let (seed, pubkey) = generate_test_keypair().unwrap();
390
391        let (response, responder_secret) = PairingResponse::create(
392            now,
393            &session.token,
394            &seed,
395            &pubkey,
396            "did:key:zDnaTest".to_string(),
397            None,
398        )
399        .unwrap();
400
401        let device_ecdh_bytes = response.device_ephemeral_pubkey_bytes().unwrap();
402        let initiator_secret = session.complete_exchange(&device_ecdh_bytes).unwrap();
403
404        assert_eq!(*initiator_secret, *responder_secret);
405    }
406
407    #[test]
408    fn test_session_consumed_prevents_reuse() {
409        let now = chrono::Utc::now();
410        let mut session = make_token();
411        let (seed, pubkey) = generate_test_keypair().unwrap();
412
413        let (response, _) = PairingResponse::create(
414            now,
415            &session.token,
416            &seed,
417            &pubkey,
418            "did:key:zDnaTest".to_string(),
419            None,
420        )
421        .unwrap();
422
423        let device_ecdh_bytes = response.device_ephemeral_pubkey_bytes().unwrap();
424
425        assert!(session.complete_exchange(&device_ecdh_bytes).is_ok());
426
427        let result = session.complete_exchange(&device_ecdh_bytes);
428        assert!(matches!(result, Err(ProtocolError::SessionConsumed)));
429    }
430}