Skip to main content

derec_library/primitives/pairing/
response.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 DeRec Alliance. All rights reserved.
3
4use crate::derec_message::{DeRecMessageBuilder, current_timestamp};
5use crate::primitives::pairing::PairingError;
6use crate::protocol_version::ProtocolVersion;
7use crate::types::ChannelId;
8use crate::utils::verify_timestamps;
9use derec_cryptography::pairing::{
10    self as cryptography_pairing, PairingSecretKeyMaterial, PairingSharedKey,
11};
12use derec_proto::{
13    CommunicationInfo, ContactMessage, ContactMode, DeRecMessage, DeRecResult, MessageBody,
14    PairRequestMessage, PairResponseMessage, PrePairRequestMessage, PrePairResponseMessage,
15    StatusEnum, TransportProtocol,
16};
17use prost::Message;
18use sha2::{Digest, Sha384};
19use subtle::ConstantTimeEq;
20
21pub struct ProduceResult {
22    /// Serialized outer [`derec_proto::DeRecMessage`] wire bytes carrying an encrypted inner
23    /// [`derec_proto::PairResponseMessage`]. Ready to send over transport.
24    pub envelope: Vec<u8>,
25    pub peer_transport_protocol: TransportProtocol,
26    pub shared_key: PairingSharedKey,
27    /// Channel identifier the responder is committing to for all future
28    /// traffic on this channel — derived from the pre-rekey id and the
29    /// freshly negotiated `shared_key` via a deterministic hash. Callers
30    /// MUST rename their local channel record from the old id to this
31    /// value once they finish handling this response.
32    pub channel_id: ChannelId,
33}
34
35pub struct ExtractResult {
36    pub response: PairResponseMessage,
37}
38
39pub struct ProcessResult {
40    pub shared_key: PairingSharedKey,
41    /// Channel identifier both peers MUST switch to for all future traffic
42    /// on this channel. Already validated against the local derivation; the
43    /// caller's only remaining job is to atomically rename its channel
44    /// record from the old id to this value.
45    pub channel_id: ChannelId,
46}
47
48pub struct ProducePrePairResult {
49    /// Serialized outer [`derec_proto::DeRecMessage`] wire bytes carrying a **plaintext**
50    /// inner [`derec_proto::PrePairResponseMessage`]. Ready to send over transport.
51    pub envelope: Vec<u8>,
52}
53
54pub struct ProducePrePairNoKeysResult {
55    /// Serialized outer [`derec_proto::DeRecMessage`] wire bytes carrying a
56    /// **plaintext** inner [`derec_proto::PrePairResponseMessage`] whose
57    /// key fields are populated with freshly-generated material. Ready to
58    /// send over transport.
59    pub envelope: Vec<u8>,
60    /// Initiator-side [`PairingSecretKeyMaterial`] generated on the fly
61    /// for this response. The caller MUST persist this — the incoming
62    /// [`derec_proto::PairRequestMessage`] will be encrypted to the
63    /// embedded ECIES public key, and finalization needs the matching
64    /// secret key material.
65    pub pairing_secret_key_material: PairingSecretKeyMaterial,
66}
67
68pub struct PrePairExtractResult {
69    pub response: PrePairResponseMessage,
70}
71
72pub struct ProcessPrePairResult {
73    pub mlkem_encapsulation_key: Vec<u8>,
74    pub ecies_public_key: Vec<u8>,
75    pub nonce: u64,
76}
77
78/// Produces a pairing response envelope and derives the final pairing shared key on the
79/// **Initiator** side (the party that originally created the contact message).
80///
81/// After receiving the responder's pairing request, the initiator:
82///
83/// 1. Validates the provided [`derec_proto::PairRequestMessage`] contents
84/// 2. Finalizes the initiator-side pairing computation using its previously stored
85///    [`derec_cryptography::pairing::PairingSecretKeyMaterial`]
86/// 3. Derives the final 256-bit [`derec_cryptography::pairing::PairingSharedKey`]
87/// 4. Constructs a [`derec_proto::PairResponseMessage`] with an `Ok` status
88/// 5. Encrypts the serialized inner response message to the responder's ECIES public key
89/// 6. Wraps the encrypted inner bytes into a plain [`derec_proto::DeRecMessage`] envelope
90///
91/// Because the pairing flow does not yet rely on the final shared symmetric key for
92/// transport, this function uses the pairing-specific **asymmetric** encryption mechanism
93/// for the inner response message.
94///
95/// To signal rejection instead of acceptance, callers build the
96/// [`derec_proto::PairResponseMessage`] themselves (with a non-`Ok`
97/// [`derec_proto::StatusEnum`]) and encrypt it with [`crate::derec_message::DeRecMessageBuilder::pairing`].
98///
99/// # Arguments
100///
101/// * `request` - The decoded [`derec_proto::PairRequestMessage`] previously returned by
102///   [`super::request::extract`].
103/// * `pairing_secret_key_material` - Initiator-side pairing secret state previously returned
104///   by [`super::request::create_contact`]. Must be the
105///   [`derec_cryptography::pairing::PairingSecretKeyMaterial::Initiator`] variant; passing the
106///   `Responder` variant will return [`PairingError::Invariant`].
107/// * `communication_info` - Optional application-level identity metadata to advertise to the
108///   peer (free-form key/value pairs). Pass `None` to send no metadata; the protocol treats
109///   this as opaque.
110///
111/// # Returns
112///
113/// On success returns [`ProduceResult`] containing:
114///
115/// - `envelope`: serialized outer [`derec_proto::DeRecMessage`] envelope bytes carrying
116///   the encrypted inner [`derec_proto::PairResponseMessage`]
117/// - `shared_key`: the initiator-side derived pairing shared key
118/// - `peer_transport_protocol`: peer transport information extracted from the
119///   validated [`derec_proto::PairRequestMessage`]
120///
121/// # Errors
122///
123/// Returns [`crate::Error`] (specifically `Error::Pairing(...)`) in the following cases:
124///
125/// - [`PairingError::InvalidPairRequestMessage`] if the request is malformed or missing fields
126/// - [`PairingError::EmptyTransportUri`] if the request transport information is missing or empty
127/// - [`PairingError::Invariant`] if `pairing_secret_key_material` is not the `Initiator` variant
128/// - [`PairingError::FinishPairingInitiator`] if pairing finalization fails
129/// - [`PairingError::PairingEncryption`] if inner-message encryption fails
130///
131/// # Security Notes
132///
133/// - The derived shared key should be treated as sensitive material.
134/// - The returned `peer_transport_protocol` is peer-provided data; apply any
135///   caller-side validation required by the selected transport layer before using it.
136///
137/// # Channel id rekey
138///
139/// After the handshake completes both sides switch from the pre-rekey
140/// `channel_id` (the contact-time value) to a post-handshake id derived
141/// deterministically from the pre-rekey id and the freshly-negotiated
142/// `shared_key` via a SHA-384 hash. This response is the
143/// **only** place the new id crosses the wire, and it travels inside the
144/// encrypted inner message; a passive observer who saw only pre-rekey
145/// traffic cannot link the long-running channel back to its pairing-time
146/// id without the shared key.
147///
148/// The outer envelope still routes on the pre-rekey id — the requester has
149/// no way to know the new id until it has decrypted the inner message and
150/// derived its own copy of the shared key.
151///
152/// # Example
153///
154/// ```
155/// use derec_library::primitives::pairing::{request, response};
156/// use derec_library::types::ChannelId;
157/// use derec_proto::{ContactMode, Protocol, SenderKind, TransportProtocol};
158///
159/// // Initiator side: create the out-of-band contact message.
160/// let request::CreateContactResult {
161///     contact_message,
162///     secret_key: initiator_key,
163/// } = request::create_contact(
164///     ChannelId(42),
165///     ContactMode::InlineKeys,
166///     TransportProtocol {
167///         uri: "https://relay.example/initiator".to_owned(),
168///         protocol: Protocol::Https.into(),
169///     },
170///     None,
171/// ).expect("create_contact failed");
172///
173/// // Responder side: build and send the pairing request envelope.
174/// let request::ProduceResult { envelope: request_envelope, .. } = request::produce(
175///     SenderKind::Helper,
176///     TransportProtocol {
177///         uri: "https://relay.example/responder".to_owned(),
178///         protocol: Protocol::Https.into(),
179///     },
180///     &contact_message,
181///     None,
182///     None,
183/// ).expect("produce failed");
184///
185/// // Initiator side: extract the pairing request, then produce the response.
186/// let request::ExtractResult { request: pair_request } = request::extract(
187///     &request_envelope,
188///     initiator_key.as_ref().unwrap().ecies_secret_key(),
189/// ).expect("extract failed");
190///
191/// let response::ProduceResult { envelope, shared_key, .. } = response::produce(
192///     ChannelId(42),
193///     &pair_request,
194///     initiator_key.as_ref().unwrap(),
195///     None,
196///     None,
197/// ).expect("produce failed");
198///
199/// assert!(!envelope.is_empty());
200/// let _ = shared_key;
201/// ```
202#[cfg_attr(
203    feature = "logging",
204    tracing::instrument(skip_all, fields(channel_id = channel_id.0))
205)]
206pub fn produce(
207    channel_id: ChannelId,
208    request: &PairRequestMessage,
209    pairing_secret_key_material: &PairingSecretKeyMaterial,
210    communication_info: Option<CommunicationInfo>,
211    parameter_range: Option<derec_proto::ParameterRange>,
212) -> Result<ProduceResult, crate::Error> {
213    validate_produce_inputs(request)?;
214
215    let peer_transport_protocol = extract_peer_transport_protocol(request)?;
216
217    let pairing_request = cryptography_pairing::PairingRequestMessageMaterial {
218        mlkem_ciphertext: request.mlkem_ciphertext.clone(),
219        ecies_public_key: request.ecies_public_key.clone(),
220    };
221
222    let initiator_material = match pairing_secret_key_material {
223        cryptography_pairing::PairingSecretKeyMaterial::Initiator(m) => m,
224        _ => {
225            return Err(PairingError::Invariant(
226                "expected Initiator key material for pairing response",
227            )
228            .into());
229        }
230    };
231
232    let shared_key =
233        cryptography_pairing::finish_pairing_initiator(initiator_material, &pairing_request)
234            .map_err(|e| PairingError::FinishPairingInitiator { source: e })?;
235
236    let rekeyed_channel_id = derive_rekeyed_channel_id(channel_id, &shared_key);
237
238    let timestamp = current_timestamp();
239    let response = PairResponseMessage {
240        result: Some(DeRecResult {
241            status: StatusEnum::Ok as i32,
242            memo: String::new(),
243        }),
244        nonce: request.nonce,
245        communication_info,
246        parameter_range,
247        timestamp: Some(timestamp),
248        channel_id: rekeyed_channel_id.into(),
249    };
250
251    let envelope = DeRecMessageBuilder::pairing()
252        .channel_id(channel_id)
253        .timestamp(timestamp)
254        .message_body(MessageBody::PairResponse(response))
255        .encrypt_pairing(&request.ecies_public_key)?
256        .build()?
257        .encode_to_vec();
258
259    #[cfg(feature = "logging")]
260    tracing::info!("pairing response envelope produced; initiator shared key derived");
261
262    Ok(ProduceResult {
263        envelope,
264        shared_key,
265        peer_transport_protocol,
266        channel_id: rekeyed_channel_id,
267    })
268}
269
270/// Produces a `PrePairResponseMessage` envelope on the **contact creator** side,
271/// the second leg of the [`derec_proto::ContactMode::HashedKeys`] pairing flow.
272///
273/// In `HASHED_KEYS` mode the contact carries only a SHA-384 commitment to the
274/// initiator's public keys (not the keys themselves), so before the scanner can
275/// build a [`PairRequestMessage`] it must ask the contact creator for the actual
276/// keys. This function builds the reply.
277///
278/// The inner [`PrePairResponseMessage`] is **plaintext** — no shared key exists yet
279/// — so the outer [`DeRecMessage`] envelope is constructed directly rather than
280/// through the encryption-enforcing [`DeRecMessageBuilder`]. The envelope's
281/// `channelId` is the local pairing channel (the same one the request arrived on),
282/// and the response echoes the request's `nonce` so the scanner can correlate it
283/// with the original contact and reject stale or spoofed replies.
284///
285/// # Arguments
286///
287/// * `channel_id` - Identifier of the local pairing channel this response belongs
288///   to. Echoed on the outer envelope so the peer can route the reply.
289/// * `request` - The decoded [`PrePairRequestMessage`] previously returned by
290///   [`super::request::extract_pre_pair`]. Only its `nonce` is consumed.
291/// * `pairing_secret_key_material` - Initiator-side pairing secret state previously
292///   returned by [`super::request::create_contact`]. Must be the
293///   [`PairingSecretKeyMaterial::Initiator`] variant; the responder variant cannot
294///   serve a `PrePairResponse` because it doesn't own the keys.
295///
296/// # Returns
297///
298/// On success returns [`ProducePrePairResult`] containing the serialized outer
299/// [`DeRecMessage`] envelope bytes.
300///
301/// # Errors
302///
303/// Returns [`crate::Error`] (specifically `Error::Pairing(...)`) in the following cases:
304///
305/// - [`PairingError::Invariant`] if `pairing_secret_key_material` is not the
306///   `Initiator` variant
307///
308/// # Security Notes
309///
310/// - The envelope is plaintext; any passive observer can read the public keys
311///   advertised here. The keys are public material, but the transport endpoint
312///   used for this exchange MUST be ephemeral (see the security note on
313///   `PrePairRequestMessage`) so the keys cannot be linked to a long-term
314///   identity by a passive observer.
315#[cfg_attr(
316    feature = "logging",
317    tracing::instrument(skip_all, fields(channel_id = channel_id.0))
318)]
319pub fn produce_pre_pair(
320    channel_id: ChannelId,
321    request: &PrePairRequestMessage,
322    pairing_secret_key_material: &PairingSecretKeyMaterial,
323) -> Result<ProducePrePairResult, crate::Error> {
324    let initiator_material = match pairing_secret_key_material {
325        PairingSecretKeyMaterial::Initiator(m) => m,
326        _ => {
327            #[cfg(feature = "logging")]
328            tracing::warn!("expected Initiator key material for PrePair response");
329
330            return Err(PairingError::Invariant(
331                "expected Initiator key material for PrePair response",
332            )
333            .into());
334        }
335    };
336
337    let timestamp = current_timestamp();
338    let response = PrePairResponseMessage {
339        result: Some(DeRecResult {
340            status: StatusEnum::Ok as i32,
341            memo: String::new(),
342        }),
343        mlkem_encapsulation_key: Some(initiator_material.mlkem_encapsulation_key.clone()),
344        ecies_public_key: Some(initiator_material.ecies_public_key.clone()),
345        nonce: request.nonce,
346        timestamp: Some(timestamp),
347    };
348
349    let protocol_version = ProtocolVersion::current();
350    let envelope = DeRecMessage {
351        protocol_version_major: protocol_version.major,
352        protocol_version_minor: protocol_version.minor,
353        sequence: 0,
354        channel_id: channel_id.into(),
355        timestamp: Some(timestamp),
356        message: MessageBody::PrePairResponse(response).encode_to_vec(),
357        trace_id: 0,
358    }
359    .encode_to_vec();
360
361    #[cfg(feature = "logging")]
362    tracing::info!("PrePair response envelope produced");
363
364    Ok(ProducePrePairResult { envelope })
365}
366
367/// Produces a [`derec_proto::PrePairResponseMessage`] envelope on the
368/// **contact creator** side, for the [`ContactMode::NoKeys`] flow.
369///
370/// Unlike [`produce_pre_pair`], no pre-existing
371/// [`PairingSecretKeyMaterial`] is required — this function **generates
372/// fresh ML-KEM and ECIES key material on the fly** and returns both the
373/// on-the-wire envelope and the new [`PairingSecretKeyMaterial`] so the
374/// caller can persist it. The subsequent
375/// [`derec_proto::PairRequestMessage`] the scanner sends will be encrypted
376/// to the embedded ECIES public key; finalization needs the matching
377/// secret key material returned here.
378///
379/// The inner [`PrePairResponseMessage`] is plaintext (same as the
380/// HashedKeys leg) — no shared key exists yet — and the `nonce` from the
381/// request is echoed for session correlation on the scanner side.
382///
383/// # Security
384///
385/// This response carries no cryptographic binding to any prior contact
386/// (no hash to verify against). Its safety derives entirely from the
387/// out-of-band delivery having established that the incoming
388/// `PrePairRequest` came from the intended, already-trusted recipient of
389/// the corresponding `ContactMessage`. The handler MUST authenticate the
390/// incoming request by matching `request.nonce` against the stored
391/// contact's `nonce` before calling this function.
392///
393/// # Arguments
394///
395/// * `channel_id` — Local pairing channel identifier this response
396///   belongs to. Echoed on the outer envelope so the scanner can route
397///   the reply.
398/// * `request` — The decoded [`PrePairRequestMessage`] previously
399///   returned by [`super::request::extract_pre_pair`]. Only its `nonce`
400///   is consumed here (echoed into the response); nonce verification is
401///   the caller's responsibility.
402///
403/// # Returns
404///
405/// On success returns [`ProducePrePairNoKeysResult`] containing the
406/// serialized envelope AND the freshly-generated
407/// [`PairingSecretKeyMaterial`]. The caller MUST persist the secret
408/// material — it's the only place ML-KEM decapsulation / ECIES decrypt
409/// keys exist for this channel.
410///
411/// # Errors
412///
413/// Returns [`crate::Error::Pairing`] wrapping
414/// [`PairingError::ContactMessageKeygen`] if key generation fails.
415#[cfg_attr(
416    feature = "logging",
417    tracing::instrument(skip_all, fields(channel_id = channel_id.0))
418)]
419pub fn produce_pre_pair_no_keys(
420    channel_id: ChannelId,
421    request: &PrePairRequestMessage,
422) -> Result<ProducePrePairNoKeysResult, crate::Error> {
423    let seed = crate::utils::generate_seed::<32>();
424    let (pk, secret_key) = cryptography_pairing::contact_message(*seed)
425        .map_err(|e| PairingError::ContactMessageKeygen { source: e })?;
426
427    let timestamp = current_timestamp();
428    let response = PrePairResponseMessage {
429        result: Some(DeRecResult {
430            status: StatusEnum::Ok as i32,
431            memo: String::new(),
432        }),
433        mlkem_encapsulation_key: Some(pk.mlkem_encapsulation_key.clone()),
434        ecies_public_key: Some(pk.ecies_public_key.clone()),
435        nonce: request.nonce,
436        timestamp: Some(timestamp),
437    };
438
439    let protocol_version = ProtocolVersion::current();
440    let envelope = DeRecMessage {
441        protocol_version_major: protocol_version.major,
442        protocol_version_minor: protocol_version.minor,
443        sequence: 0,
444        channel_id: channel_id.into(),
445        timestamp: Some(timestamp),
446        message: MessageBody::PrePairResponse(response).encode_to_vec(),
447        trace_id: 0,
448    }
449    .encode_to_vec();
450
451    #[cfg(feature = "logging")]
452    tracing::info!("NoKeys PrePair response envelope produced with fresh key material");
453
454    Ok(ProducePrePairNoKeysResult {
455        envelope,
456        pairing_secret_key_material: PairingSecretKeyMaterial::Initiator(secret_key),
457    })
458}
459
460/// Decrypts and decodes an incoming [`derec_proto::PairResponseMessage`] from an outer
461/// [`derec_proto::DeRecMessage`] envelope.
462///
463/// Because pairing happens *before* a shared symmetric key exists, the inner message is
464/// decrypted using the pairing-specific **asymmetric** ECIES decryption mechanism.
465///
466/// This function:
467///
468/// 1. Decodes the outer [`derec_proto::DeRecMessage`] envelope from `envelope_bytes`
469/// 2. Decrypts the inner message bytes using `ecies_secret_key`
470/// 3. Decodes the decrypted bytes as a [`derec_proto::PairResponseMessage`]
471/// 4. Validates the invariant `envelope.timestamp == response.timestamp`
472///
473/// # Arguments
474///
475/// * `envelope_bytes` - Serialized outer [`derec_proto::DeRecMessage`] bytes carrying an
476///   asymmetrically-encrypted inner [`derec_proto::PairResponseMessage`], as produced by
477///   [`produce`].
478/// * `ecies_secret_key` - The responder's ECIES secret key. Must correspond to the
479///   `ecies_public_key` the responder embedded in their [`derec_proto::PairRequestMessage`],
480///   which is the key used by [`produce`] to encrypt the inner response.
481///
482/// # Returns
483///
484/// On success returns [`ExtractResult`] containing:
485///
486/// - `response`: the decrypted inner [`derec_proto::PairResponseMessage`]
487///
488/// # Errors
489///
490/// Returns [`crate::Error`] if:
491///
492/// - `envelope_bytes` cannot be decoded as a valid [`derec_proto::DeRecMessage`]
493/// - ECIES decryption fails
494/// - the decrypted bytes cannot be decoded as a [`derec_proto::PairResponseMessage`]
495/// - `envelope.timestamp != response.timestamp`
496/// - the inner message is not a [`derec_proto::PairResponseMessage`]
497///
498/// # Security: no freshness or replay protection
499///
500/// The timestamp check enforced here only binds the envelope to the
501/// inner body (`envelope.timestamp == body.timestamp`). It does NOT
502/// enforce a freshness window against the receiver's clock and does
503/// NOT detect replays of a previously-captured ciphertext. Pairing
504/// has a small extra mitigation (the per-channel `ContactMessage`
505/// nonce is one-shot, so a replayed PairResponse cannot complete a
506/// fresh pairing once the original session is over), but a recorded
507/// envelope can still be re-decoded and inspected later. Callers
508/// MUST add a freshness window and per-channel anti-replay
509/// (monotonic counter or nonce log) on top before driving any
510/// side-effecting state off the parsed body.
511///
512/// # Example
513///
514/// ```
515/// use derec_library::primitives::pairing::{request, response};
516/// use derec_library::types::ChannelId;
517/// use derec_proto::{ContactMode, Protocol, SenderKind, TransportProtocol};
518///
519/// // Initiator: create the out-of-band contact message.
520/// let request::CreateContactResult {
521///     contact_message,
522///     secret_key: initiator_key,
523/// } = request::create_contact(
524///     ChannelId(42),
525///     ContactMode::InlineKeys,
526///     TransportProtocol {
527///         uri: "https://relay.example/initiator".to_owned(),
528///         protocol: Protocol::Https.into(),
529///     },
530///     None,
531/// ).expect("create_contact failed");
532///
533/// // Responder: build the pairing request envelope.
534/// let request::ProduceResult {
535///     envelope: request_envelope,
536///     secret_key: responder_key,
537///     ..
538/// } = request::produce(
539///     SenderKind::Helper,
540///     TransportProtocol {
541///         uri: "https://relay.example/responder".to_owned(),
542///         protocol: Protocol::Https.into(),
543///     },
544///     &contact_message,
545///     None,
546///     None,
547/// ).expect("produce failed");
548///
549/// // Initiator: extract the pairing request, then produce the response.
550/// let request::ExtractResult { request: pair_request } = request::extract(
551///     &request_envelope,
552///     initiator_key.as_ref().unwrap().ecies_secret_key(),
553/// ).expect("extract request failed");
554/// let response::ProduceResult { envelope: response_envelope, .. } =
555///     response::produce(ChannelId(42), &pair_request, initiator_key.as_ref().unwrap(), None, None)
556///         .expect("produce failed");
557///
558/// // Responder: decrypt the pairing response.
559/// let response::ExtractResult { response } =
560///     response::extract(&response_envelope, responder_key.ecies_secret_key())
561///         .expect("extract response failed");
562///
563/// assert_eq!(response.nonce, pair_request.nonce);
564/// ```
565#[cfg_attr(
566    feature = "logging",
567    tracing::instrument(skip_all, fields(envelope_len = envelope_bytes.len()))
568)]
569pub fn extract(
570    envelope_bytes: &[u8],
571    ecies_secret_key: &[u8],
572) -> Result<ExtractResult, crate::Error> {
573    let envelope = DeRecMessage::decode(envelope_bytes).map_err(crate::Error::ProtobufDecode)?;
574
575    let plaintext =
576        derec_cryptography::pairing::envelope::decrypt(&envelope.message, ecies_secret_key)
577            .map_err(PairingError::PairingEncryption)?;
578
579    let response = match MessageBody::decode_from_vec(plaintext.as_slice())
580        .map_err(crate::Error::ProtobufDecode)?
581    {
582        MessageBody::PairResponse(r) => r,
583        _ => {
584            #[cfg(feature = "logging")]
585            tracing::warn!("unexpected message type; expected PairResponseMessage");
586
587            return Err(crate::Error::Invariant(
588                "Invalid message. Expected: PairResponseMessage",
589            ));
590        }
591    };
592
593    verify_timestamps(envelope.timestamp, response.timestamp)?;
594
595    #[cfg(feature = "logging")]
596    tracing::info!("pairing response extracted and validated");
597
598    Ok(ExtractResult { response })
599}
600
601/// Decodes a plaintext [`PrePairResponseMessage`] from an outer
602/// [`DeRecMessage`] envelope produced by [`produce_pre_pair`].
603///
604/// Like its request-side counterpart [`super::request::extract_pre_pair`],
605/// the `PrePair` flow carries its inner message **in plaintext** inside the
606/// envelope (no shared key exists yet, and the keys the response is delivering
607/// cannot themselves be used for encryption). This function performs no
608/// decryption — it decodes the envelope, decodes the inner [`MessageBody`],
609/// and validates the envelope-vs-body timestamp invariant.
610///
611/// Status and content validation (confirming `result.status == OK` and
612/// recomputing the SHA-384 binding hash against
613/// [`derec_proto::ContactMessage::contact_binding_hash`]) is the caller's
614/// responsibility — see the receiver checklist on
615/// [`derec_proto::PrePairResponseMessage`].
616///
617/// # Arguments
618///
619/// * `envelope_bytes` - Serialized outer [`DeRecMessage`] wire bytes, as
620///   produced by [`produce_pre_pair`].
621///
622/// # Returns
623///
624/// On success returns [`PrePairExtractResult`] containing the decoded inner
625/// [`PrePairResponseMessage`]. The caller can recover the routing
626/// `channel_id` by decoding the envelope separately if it is not already
627/// known from context.
628///
629/// # Errors
630///
631/// Returns [`crate::Error`] if:
632///
633/// - `envelope_bytes` cannot be decoded as a valid [`DeRecMessage`]
634/// - the inner [`MessageBody`] cannot be decoded
635/// - the inner [`MessageBody`] is not a [`PrePairResponseMessage`]
636/// - `envelope.timestamp != response.timestamp`
637///
638/// # Security: no freshness or replay protection
639///
640/// The timestamp check enforced here only binds the envelope to the
641/// inner body (`envelope.timestamp == body.timestamp`). It does NOT
642/// enforce a freshness window against the receiver's clock and does
643/// NOT detect replays of a previously-captured envelope. PrePair
644/// envelopes are plaintext (no shared key yet), so a recorded
645/// envelope can be replayed verbatim by anyone on path. Callers
646/// MUST add a freshness window and per-channel anti-replay
647/// (monotonic counter or nonce log) on top before driving any
648/// side-effecting state off the parsed body.
649#[cfg_attr(
650    feature = "logging",
651    tracing::instrument(skip_all, fields(envelope_len = envelope_bytes.len()))
652)]
653pub fn extract_pre_pair(envelope_bytes: &[u8]) -> Result<PrePairExtractResult, crate::Error> {
654    let envelope = DeRecMessage::decode(envelope_bytes).map_err(crate::Error::ProtobufDecode)?;
655
656    let response = match crate::derec_message::extract_inner_plaintext_message(&envelope.message)? {
657        MessageBody::PrePairResponse(r) => r,
658        _ => {
659            #[cfg(feature = "logging")]
660            tracing::warn!("unexpected message type; expected PrePairResponseMessage");
661
662            return Err(crate::Error::Invariant(
663                "Invalid message. Expected: PrePairResponseMessage",
664            ));
665        }
666    };
667
668    verify_timestamps(envelope.timestamp, response.timestamp)?;
669
670    #[cfg(feature = "logging")]
671    tracing::info!("PrePair response envelope decoded and validated");
672
673    Ok(PrePairExtractResult { response })
674}
675
676/// Processes a decrypted pairing response and derives the final pairing shared key on the
677/// **Responder** side.
678///
679/// This function is executed by the party that:
680///
681/// 1. Received the initiator's contact out-of-band
682/// 2. Produced and sent a pairing request using [`super::request::produce`]
683///
684/// After receiving the initiator's decrypted pairing response, the responder:
685///
686/// 1. Validates the response status and binds it to the same pairing session using the nonce
687/// 2. Uses the responder's previously stored [`derec_cryptography::pairing::PairingSecretKeyMaterial`]
688///    together with the initiator's original public pairing material from the contact message
689///    to finalize the responder side
690/// 3. Derives the final 256-bit [`derec_cryptography::pairing::PairingSharedKey`]
691///
692/// Both parties should derive the same shared key if the pairing flow completed successfully.
693///
694/// # Arguments
695///
696/// * `contact_message` - Decoded [`derec_proto::ContactMessage`] previously returned as
697///   `initiator_contact_message` by [`super::request::produce`].
698/// * `response` - The decrypted [`derec_proto::PairResponseMessage`] previously returned
699///   by [`extract`].
700/// * `pairing_secret_key_material` - Responder-side secret state previously returned by
701///   [`super::request::produce`]. Must be the
702///   [`derec_cryptography::pairing::PairingSecretKeyMaterial::Responder`] variant; passing the
703///   `Initiator` variant will return [`PairingError::Invariant`].
704///
705/// # Returns
706///
707/// On success returns [`ProcessResult`] containing:
708///
709/// - `shared_key`: the responder-side derived pairing shared key
710///
711/// # Errors
712///
713/// Returns [`crate::Error`] (specifically `Error::Pairing(...)`) in the following cases:
714///
715/// - [`PairingError::NonOkStatus`] if `result.status != Ok`, carrying the peer's status code
716///   and memo string
717/// - [`PairingError::InvalidPairResponseMessage`] if the response is malformed (e.g. missing result)
718/// - [`PairingError::InvalidContactMessage`] if the contact message is missing required fields
719/// - [`PairingError::ProtocolViolation`] if the response does not match the pairing session
720///   (for example, nonce mismatch)
721/// - [`PairingError::Invariant`] if `pairing_secret_key_material` is not the `Responder` variant
722/// - [`PairingError::FinishPairingResponder`] if final pairing derivation fails
723///
724/// # Security Notes
725///
726/// - The nonce check is a critical session-binding validation.
727/// - The derived shared key should be treated as sensitive material and stored securely.
728///
729/// # Example
730///
731/// ```
732/// use derec_library::primitives::pairing::{request, response};
733/// use derec_library::types::ChannelId;
734/// use derec_proto::{ContactMode, Protocol, SenderKind, TransportProtocol};
735///
736/// // Initiator side: create the out-of-band contact message.
737/// let request::CreateContactResult {
738///     contact_message,
739///     secret_key: initiator_key,
740/// } = request::create_contact(
741///     ChannelId(42),
742///     ContactMode::InlineKeys,
743///     TransportProtocol {
744///         uri: "https://relay.example/initiator".to_owned(),
745///         protocol: Protocol::Https.into(),
746///     },
747///     None,
748/// ).expect("create_contact failed");
749///
750/// // Responder side: build and send the pairing request envelope.
751/// let request::ProduceResult {
752///     envelope: request_envelope,
753///     initiator_contact_message,
754///     secret_key: responder_key,
755/// } = request::produce(
756///     SenderKind::Helper,
757///     TransportProtocol {
758///         uri: "https://relay.example/responder".to_owned(),
759///         protocol: Protocol::Https.into(),
760///     },
761///     &contact_message,
762///     None,
763///     None,
764/// ).expect("produce failed");
765///
766/// // Initiator side: extract the pairing request and produce the response.
767/// let request::ExtractResult { request: pair_request } = request::extract(
768///     &request_envelope,
769///     initiator_key.as_ref().unwrap().ecies_secret_key(),
770/// ).expect("extract request failed");
771///
772/// let response::ProduceResult { envelope: response_envelope, shared_key: initiator_shared_key, .. } =
773///     response::produce(ChannelId(42), &pair_request, initiator_key.as_ref().unwrap(), None, None)
774///         .expect("produce failed");
775///
776/// // Responder side: extract the pairing response and derive the shared key.
777/// let response::ExtractResult { response: pair_response } = response::extract(
778///     &response_envelope,
779///     responder_key.ecies_secret_key(),
780/// ).expect("extract response failed");
781///
782/// let response::ProcessResult { shared_key: responder_shared_key, .. } =
783///     response::process(&initiator_contact_message, &pair_response, &responder_key)
784///         .expect("process failed");
785///
786/// assert_eq!(initiator_shared_key, responder_shared_key);
787/// ```
788#[cfg_attr(
789    feature = "logging",
790    tracing::instrument(skip_all, fields(channel_id = contact_message.channel_id))
791)]
792pub fn process(
793    contact_message: &ContactMessage,
794    response: &PairResponseMessage,
795    pairing_secret_key_material: &PairingSecretKeyMaterial,
796) -> Result<ProcessResult, crate::Error> {
797    let responder_material =
798        validate_process_inputs(contact_message, response, pairing_secret_key_material)?;
799
800    let pairing_contact_key_material = build_pairing_contact_material(contact_message);
801
802    let shared_key = cryptography_pairing::finish_pairing_responder(
803        responder_material,
804        &pairing_contact_key_material,
805    )
806    .map_err(|e| PairingError::FinishPairingResponder { source: e })?;
807
808    let expected_channel_id =
809        validate_rekeyed_channel_id(response, ChannelId(contact_message.channel_id), &shared_key)?;
810
811    #[cfg(feature = "logging")]
812    tracing::info!("pairing complete; responder shared key derived");
813
814    Ok(ProcessResult {
815        shared_key,
816        channel_id: expected_channel_id,
817    })
818}
819
820/// Validates an incoming [`PrePairResponseMessage`] against the original
821/// [`ContactMessage`] and, on success, hands back the initiator's public
822/// keys ready to be used to construct a normal [`PairRequestMessage`].
823///
824/// This is the **scanner**-side check that closes the
825/// [`derec_proto::ContactMode::HashedKeys`] pre-pair leg. Per the receiver
826/// checklist on [`derec_proto::PrePairResponseMessage`], a conforming
827/// implementation MUST:
828///
829/// 1. confirm `result.status == OK`;
830/// 2. recompute `SHA-384(mlkemEncapsulationKey || eciesPublicKey
831///    || u64_be(nonce) || u64_be(channelId))` and verify it matches the
832///    original [`ContactMessage::contact_binding_hash`];
833/// 3. only on match, proceed to construct a normal [`PairRequestMessage`].
834///
835/// This function performs all three checks. The recomputation uses the
836/// `nonce` and `channelId` from the **contact** — the trusted out-of-band
837/// values — so a tampered response that swapped them cannot mask a hash
838/// mismatch by tampering them in parallel.
839///
840/// # Arguments
841///
842/// * `contact_message` - The decoded [`ContactMessage`] received out-of-band.
843///   Its [`ContactMode`] must be [`ContactMode::HashedKeys`] and it must
844///   carry a non-empty `contact_binding_hash`.
845/// * `response` - The decoded [`PrePairResponseMessage`] previously returned
846///   by [`extract_pre_pair`].
847///
848/// # Returns
849///
850/// On success returns [`ProcessPrePairResult`] containing the validated
851/// `mlkem_encapsulation_key`, `ecies_public_key`, and the echoed `nonce`.
852///
853/// # Errors
854///
855/// Returns [`crate::Error`] (specifically `Error::Pairing(...)`) in the following cases:
856///
857/// - [`PairingError::InvalidPairResponseMessage`] if the response is malformed
858///   (missing result, missing/empty keys)
859/// - [`PairingError::NonOkStatus`] if `result.status != Ok`, carrying the
860///   peer's status code and memo string
861/// - [`PairingError::InvalidContactMessage`] if the contact is not in
862///   [`ContactMode::HashedKeys`] or lacks a `contact_binding_hash`
863/// - [`PairingError::ProtocolViolation`] if `response.nonce` does not match
864///   `contact_message.nonce` (session correlation failure) or if the
865///   recomputed binding hash does not match `contact_binding_hash`
866///   (potential MITM on the plaintext pre-pair leg)
867#[cfg_attr(
868    feature = "logging",
869    tracing::instrument(skip_all, fields(channel_id = contact_message.channel_id))
870)]
871pub fn process_pre_pair(
872    contact_message: &ContactMessage,
873    response: &PrePairResponseMessage,
874) -> Result<ProcessPrePairResult, crate::Error> {
875    validate_process_pre_pair_inputs(contact_message, response, ContactMode::HashedKeys)?;
876
877    let (mlkem_encapsulation_key, ecies_public_key) =
878        validate_contact_binding_hash(contact_message, response)?;
879
880    #[cfg(feature = "logging")]
881    tracing::info!("PrePair response validated against contact binding hash");
882
883    Ok(ProcessPrePairResult {
884        mlkem_encapsulation_key,
885        ecies_public_key,
886        nonce: response.nonce,
887    })
888}
889
890/// Processes an incoming [`derec_proto::PrePairResponseMessage`] on the
891/// **scanner** side, for the [`ContactMode::NoKeys`] flow.
892///
893/// Unlike [`process_pre_pair`], this variant performs **no cryptographic
894/// verification** of the published keys against the original contact —
895/// there is no binding hash to compare, by design. The scanner accepts
896/// the ML-KEM and ECIES public keys returned by the contact creator at
897/// face value; the trust anchor is the out-of-band delivery channel,
898/// not the wire.
899///
900/// The shared invariants are still enforced: the response status must be
901/// `Ok`, the two key fields must be non-empty, the echoed `nonce` must
902/// match the contact's, and the contact itself must be structurally
903/// consistent with [`ContactMode::NoKeys`] (no inline keys, no binding
904/// hash).
905///
906/// # Arguments
907///
908/// * `contact_message` — The original [`ContactMode::NoKeys`] contact
909///   the scanner is pairing against. Used for the `contact_mode`
910///   invariant check and the `nonce` correlation.
911/// * `response` — Decoded [`PrePairResponseMessage`] as returned by
912///   [`extract_pre_pair`].
913///
914/// # Errors
915///
916/// - [`PairingError::NonOkStatus`] on a non-`Ok` status (surface as
917///   `PrePairRejected` on the caller side).
918/// - [`PairingError::InvalidPairResponseMessage`] if either published
919///   key field is empty.
920/// - [`PairingError::InvalidContactMessage`] if the contact's declared
921///   mode is not [`ContactMode::NoKeys`] or the shape is invalid.
922/// - [`PairingError::ProtocolViolation`] on a `nonce` mismatch.
923#[cfg_attr(
924    feature = "logging",
925    tracing::instrument(skip_all, fields(channel_id = contact_message.channel_id))
926)]
927pub fn process_pre_pair_no_keys(
928    contact_message: &ContactMessage,
929    response: &PrePairResponseMessage,
930) -> Result<ProcessPrePairResult, crate::Error> {
931    validate_process_pre_pair_inputs(contact_message, response, ContactMode::NoKeys)?;
932
933    let mlkem_encapsulation_key = response
934        .mlkem_encapsulation_key
935        .clone()
936        .expect("validate_process_pre_pair_inputs guarantees mlkem_encapsulation_key is Some");
937    let ecies_public_key = response
938        .ecies_public_key
939        .clone()
940        .expect("validate_process_pre_pair_inputs guarantees ecies_public_key is Some");
941
942    #[cfg(feature = "logging")]
943    tracing::info!("NoKeys PrePair response accepted without hash verification");
944
945    Ok(ProcessPrePairResult {
946        mlkem_encapsulation_key,
947        ecies_public_key,
948        nonce: response.nonce,
949    })
950}
951
952fn validate_produce_inputs(request: &PairRequestMessage) -> Result<(), crate::Error> {
953    if request.mlkem_ciphertext.is_empty() {
954        #[cfg(feature = "logging")]
955        tracing::warn!("pair request missing mlkem_ciphertext");
956
957        return Err(PairingError::InvalidPairRequestMessage("mlkem_ciphertext is empty").into());
958    }
959
960    if request.ecies_public_key.is_empty() {
961        #[cfg(feature = "logging")]
962        tracing::warn!("pair request missing ecies_public_key");
963
964        return Err(PairingError::InvalidPairRequestMessage("ecies_public_key is empty").into());
965    }
966
967    Ok(())
968}
969
970fn extract_peer_transport_protocol(
971    request: &PairRequestMessage,
972) -> Result<TransportProtocol, crate::Error> {
973    let peer_transport_protocol = request
974        .transport_protocol
975        .clone()
976        .ok_or(PairingError::EmptyTransportUri)?;
977
978    if peer_transport_protocol.uri.trim().is_empty() {
979        #[cfg(feature = "logging")]
980        tracing::warn!("peer transport URI is empty");
981
982        return Err(PairingError::EmptyTransportUri.into());
983    }
984
985    let _ = crate::transport::TransportProtocol::try_from(&peer_transport_protocol)?;
986
987    Ok(peer_transport_protocol)
988}
989
990fn validate_process_inputs<'a>(
991    contact_message: &ContactMessage,
992    response: &PairResponseMessage,
993    pairing_secret_key_material: &'a PairingSecretKeyMaterial,
994) -> Result<&'a cryptography_pairing::ResponderSecretKeyMaterial, crate::Error> {
995    let res = response
996        .result
997        .as_ref()
998        .ok_or(PairingError::InvalidPairResponseMessage("missing result"))?;
999
1000    if res.status != StatusEnum::Ok as i32 {
1001        #[cfg(feature = "logging")]
1002        tracing::warn!(status = res.status, memo = %res.memo, "pair response status is not Ok");
1003
1004        return Err(PairingError::NonOkStatus {
1005            status: res.status,
1006            memo: res.memo.to_owned(),
1007        }
1008        .into());
1009    }
1010
1011    let mlkem_present = contact_message
1012        .mlkem_encapsulation_key
1013        .as_ref()
1014        .is_some_and(|v| !v.is_empty());
1015    if !mlkem_present {
1016        #[cfg(feature = "logging")]
1017        tracing::warn!("contact message missing mlkem_encapsulation_key");
1018        return Err(PairingError::InvalidContactMessage("mlkem_encapsulation_key is empty").into());
1019    }
1020
1021    let ecies_present = contact_message
1022        .ecies_public_key
1023        .as_ref()
1024        .is_some_and(|v| !v.is_empty());
1025    if !ecies_present {
1026        #[cfg(feature = "logging")]
1027        tracing::warn!("contact message missing ecies_public_key");
1028        return Err(PairingError::InvalidContactMessage("ecies_public_key is empty").into());
1029    }
1030
1031    if response.nonce != contact_message.nonce {
1032        #[cfg(feature = "logging")]
1033        tracing::warn!("nonce mismatch; possible replay or wrong pairing session");
1034        return Err(PairingError::ProtocolViolation("nonce mismatch").into());
1035    }
1036
1037    match pairing_secret_key_material {
1038        PairingSecretKeyMaterial::Responder(m) => Ok(m),
1039        PairingSecretKeyMaterial::Initiator(_) => Err(PairingError::Invariant(
1040            "expected Responder key material for pairing process",
1041        )
1042        .into()),
1043    }
1044}
1045
1046fn build_pairing_contact_material(
1047    contact_message: &ContactMessage,
1048) -> cryptography_pairing::PairingContactMessageMaterial {
1049    let mlkem_encapsulation_key = contact_message
1050        .mlkem_encapsulation_key
1051        .as_ref()
1052        .expect("validate_process_inputs guarantees mlkem_encapsulation_key is Some")
1053        .clone();
1054    let ecies_public_key = contact_message
1055        .ecies_public_key
1056        .as_ref()
1057        .expect("validate_process_inputs guarantees ecies_public_key is Some")
1058        .clone();
1059    cryptography_pairing::PairingContactMessageMaterial {
1060        mlkem_encapsulation_key,
1061        ecies_public_key,
1062    }
1063}
1064
1065fn validate_rekeyed_channel_id(
1066    response: &PairResponseMessage,
1067    original_channel_id: ChannelId,
1068    shared_key: &PairingSharedKey,
1069) -> Result<ChannelId, crate::Error> {
1070    let expected_channel_id = derive_rekeyed_channel_id(original_channel_id, shared_key);
1071    if response.channel_id != u64::from(expected_channel_id) {
1072        #[cfg(feature = "logging")]
1073        tracing::warn!(
1074            advertised = response.channel_id,
1075            expected = u64::from(expected_channel_id),
1076            "channel id rekey mismatch"
1077        );
1078        return Err(PairingError::ProtocolViolation("channel_id rekey mismatch").into());
1079    }
1080    Ok(expected_channel_id)
1081}
1082
1083fn validate_process_pre_pair_inputs(
1084    contact_message: &ContactMessage,
1085    response: &PrePairResponseMessage,
1086    expected_mode: ContactMode,
1087) -> Result<(), crate::Error> {
1088    let res = response
1089        .result
1090        .as_ref()
1091        .ok_or(PairingError::InvalidPairResponseMessage("missing result"))?;
1092
1093    if res.status != StatusEnum::Ok as i32 {
1094        #[cfg(feature = "logging")]
1095        tracing::warn!(
1096            status = res.status,
1097            memo = %res.memo,
1098            "PrePair response status is not Ok"
1099        );
1100
1101        return Err(PairingError::NonOkStatus {
1102            status: res.status,
1103            memo: res.memo.to_owned(),
1104        }
1105        .into());
1106    }
1107
1108    // Shape + mode invariants for the contact. Catches an attacker who
1109    // tampered with `contact_mode` between the out-of-band exchange and
1110    // this validation point, and rejects malformed contacts that carry
1111    // both inline keys and a binding hash.
1112    super::validate_contact_for_mode(contact_message, expected_mode)?;
1113
1114    let mlkem_present = response
1115        .mlkem_encapsulation_key
1116        .as_ref()
1117        .is_some_and(|v| !v.is_empty());
1118    if !mlkem_present {
1119        #[cfg(feature = "logging")]
1120        tracing::warn!("PrePair response missing mlkem_encapsulation_key");
1121        return Err(
1122            PairingError::InvalidPairResponseMessage("mlkem_encapsulation_key is empty").into(),
1123        );
1124    }
1125
1126    let ecies_present = response
1127        .ecies_public_key
1128        .as_ref()
1129        .is_some_and(|v| !v.is_empty());
1130    if !ecies_present {
1131        #[cfg(feature = "logging")]
1132        tracing::warn!("PrePair response missing ecies_public_key");
1133        return Err(PairingError::InvalidPairResponseMessage("ecies_public_key is empty").into());
1134    }
1135
1136    if response.nonce != contact_message.nonce {
1137        #[cfg(feature = "logging")]
1138        tracing::warn!("nonce mismatch; possible replay or wrong pairing session");
1139        return Err(PairingError::ProtocolViolation("nonce mismatch").into());
1140    }
1141
1142    Ok(())
1143}
1144
1145fn derive_rekeyed_channel_id(
1146    original_channel_id: ChannelId,
1147    shared_key: &PairingSharedKey,
1148) -> ChannelId {
1149    let mut hasher = Sha384::new();
1150    hasher.update(u64::from(original_channel_id).to_be_bytes());
1151    hasher.update(shared_key.as_slice());
1152    let digest = hasher.finalize();
1153    let prefix: [u8; 8] = digest[..8]
1154        .try_into()
1155        .expect("SHA-384 digest has at least 8 bytes");
1156    ChannelId(u64::from_be_bytes(prefix))
1157}
1158
1159fn validate_contact_binding_hash(
1160    contact_message: &ContactMessage,
1161    response: &PrePairResponseMessage,
1162) -> Result<(Vec<u8>, Vec<u8>), crate::Error> {
1163    let mlkem_encapsulation_key = response
1164        .mlkem_encapsulation_key
1165        .clone()
1166        .expect("validate_process_pre_pair_inputs guarantees mlkem_encapsulation_key is Some");
1167    let ecies_public_key = response
1168        .ecies_public_key
1169        .clone()
1170        .expect("validate_process_pre_pair_inputs guarantees ecies_public_key is Some");
1171    let expected_hash = contact_message
1172        .contact_binding_hash
1173        .as_ref()
1174        .expect("validate_process_pre_pair_inputs guarantees contact_binding_hash is Some");
1175
1176    let recomputed = derec_cryptography::pairing::contact_binding_hash(
1177        &mlkem_encapsulation_key,
1178        &ecies_public_key,
1179        contact_message.nonce,
1180        contact_message.channel_id,
1181    );
1182
1183    let matched: bool = recomputed.as_slice().ct_eq(expected_hash.as_slice()).into();
1184    if !matched {
1185        #[cfg(feature = "logging")]
1186        tracing::warn!(
1187            "contact binding hash mismatch; published keys do not match the contact commitment"
1188        );
1189        return Err(PairingError::PrePairHashMismatch.into());
1190    }
1191
1192    Ok((mlkem_encapsulation_key, ecies_public_key))
1193}