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}