1use std::fmt;
2
3use tracing::{debug, instrument, warn};
4
5use crate::{PaykitError, PublicKey, Result};
6
7use super::{
8 link::EncryptedLink,
9 paths::{compute_private_payment_paths, validate_private_payment_paths},
10 snapshot::EncryptedLinkHandshakeSnapshot,
11 EncryptedLinkRecoveryContext,
12};
13
14pub const DEFAULT_MAX_RECOVERY_ATTEMPTS: u32 = 3;
19
20pub struct EncryptedLinkHandshake {
31 encryptor: pubky_noise::PubkyNoiseEncryptor,
33 remote_identity_public_key: PublicKey,
35 remote_noise_public_key: PublicKey,
37 recovery_context: EncryptedLinkRecoveryContext,
38 config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
40 recovery_attempts: u32,
42 max_recovery_attempts: u32,
44}
45
46pub struct HandshakeAdvanceError {
53 error: PaykitError,
54 retry_handshake: Option<Box<EncryptedLinkHandshake>>,
55}
56
57impl HandshakeAdvanceError {
58 pub fn error(&self) -> &PaykitError {
60 &self.error
61 }
62
63 pub fn into_parts(self) -> (PaykitError, Option<Box<EncryptedLinkHandshake>>) {
66 (self.error, self.retry_handshake)
67 }
68}
69
70impl From<PaykitError> for HandshakeAdvanceError {
71 fn from(error: PaykitError) -> Self {
72 Self {
73 error,
74 retry_handshake: None,
75 }
76 }
77}
78
79impl fmt::Debug for HandshakeAdvanceError {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 f.debug_struct("HandshakeAdvanceError")
82 .field("error", &self.error)
83 .field("has_retry_handshake", &self.retry_handshake.is_some())
84 .finish()
85 }
86}
87
88impl fmt::Display for HandshakeAdvanceError {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 fmt::Display::fmt(&self.error, f)
91 }
92}
93
94impl std::error::Error for HandshakeAdvanceError {
95 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
96 Some(&self.error)
97 }
98}
99
100#[must_use]
104pub struct PreparedEncryptedLinkHandshakeStep {
105 snapshot: EncryptedLinkHandshakeSnapshot,
106 handshake_complete: bool,
107 remote_static_public_key: Option<Vec<u8>>,
108}
109
110impl PreparedEncryptedLinkHandshakeStep {
111 pub fn is_handshake_complete(&self) -> bool {
113 self.handshake_complete
114 }
115
116 pub fn remote_static_public_key(&self) -> Option<&[u8]> {
118 self.remote_static_public_key.as_deref()
119 }
120
121 pub fn into_snapshot(self) -> EncryptedLinkHandshakeSnapshot {
123 self.snapshot
124 }
125}
126
127impl EncryptedLinkHandshake {
128 pub async fn prepare_next_step(self) -> Result<Option<PreparedEncryptedLinkHandshakeStep>> {
134 let snapshot = self.snapshot()?;
135 let input = match snapshot.next_handshake_read_path(
136 self.config.local_session.info().public_key(),
137 &self.config.pubky_root_keypair.secret_key(),
138 )? {
139 Some(path) => {
140 let response = match self.config.outbox_client.public_storage().get(path).await {
141 Ok(response) => response,
142 Err(pubky::Error::Request(pubky::errors::RequestError::Server {
143 status,
144 ..
145 })) if status == pubky::StatusCode::NOT_FOUND
146 || status == pubky::StatusCode::GONE =>
147 {
148 return Ok(None)
149 }
150 Err(error) => {
151 return Err(PaykitError::Transport {
152 context: "fetch Encrypted Link Handshake packet".into(),
153 source: error.into(),
154 })
155 }
156 };
157 Some(
158 crate::pubky_routing::read_bounded_body(
159 response,
160 pubky_noise::snow_crypto::PUBKY_NOISE_CIPHERTEXT_LEN + 2,
161 "read Encrypted Link Handshake packet",
162 )
163 .await?,
164 )
165 }
166 None => None,
167 };
168 let prepared = self
169 .encryptor
170 .prepare_handshake_step(input.as_deref())
171 .map_err(|error| handshake_error("prepare Encrypted Link Handshake", error))?;
172 let mut snapshot = EncryptedLinkHandshakeSnapshot::from_state(
173 prepared.resulting_session_state().clone(),
174 self.remote_identity_public_key,
175 self.remote_noise_public_key,
176 self.recovery_context,
177 );
178 if let (Some(path), Some(packet)) = (prepared.destination_path(), prepared.packet()) {
179 snapshot = snapshot.with_pending_publication(
180 self.config.local_session.info().public_key(),
181 path.to_owned(),
182 packet.to_vec(),
183 )?;
184 }
185 Ok(Some(PreparedEncryptedLinkHandshakeStep {
186 snapshot,
187 handshake_complete: prepared.is_handshake_complete(),
188 remote_static_public_key: prepared.remote_static_public_key().map(<[u8]>::to_vec),
189 }))
190 }
191
192 pub fn finish_if_complete(self) -> Result<HandshakeProgress> {
196 if self.encryptor.is_handshake_complete() {
197 finish_handshake(self)
198 } else {
199 Ok(HandshakeProgress::Pending(self))
200 }
201 }
202
203 pub fn remote_static_public_key(&self) -> Option<&[u8]> {
207 self.encryptor.remote_static_public_key()
208 }
209
210 pub fn set_max_recovery_attempts(&mut self, max: u32) -> &mut Self {
216 self.max_recovery_attempts = max;
217 self
218 }
219
220 pub fn snapshot(&self) -> Result<EncryptedLinkHandshakeSnapshot> {
225 Ok(EncryptedLinkHandshakeSnapshot::from_state(
226 self.encryptor
227 .snapshot()
228 .map_err(|err| PaykitError::InvalidData {
229 context: format!("capture Encrypted Link Handshake snapshot: {err:?}"),
230 source: None,
231 })?,
232 self.remote_identity_public_key.clone(),
233 self.remote_noise_public_key.clone(),
234 self.recovery_context.clone(),
235 ))
236 }
237
238 pub fn serialize(&self) -> Result<Vec<u8>> {
242 Ok(self.snapshot()?.serialize())
243 }
244
245 pub fn config(&self) -> &std::sync::Arc<pubky_noise::PubkyNoiseConfig> {
250 &self.config
251 }
252
253 #[cfg(test)]
254 pub(crate) fn recovery_attempts_for_test(&self) -> u32 {
255 self.recovery_attempts
256 }
257
258 #[cfg(test)]
259 pub(crate) fn max_recovery_attempts_for_test(&self) -> u32 {
260 self.max_recovery_attempts
261 }
262}
263
264pub enum HandshakeProgress {
266 Pending(EncryptedLinkHandshake),
270
271 Complete(EncryptedLink),
274}
275
276#[instrument(skip(session, sender_secret_key, outbox_client))]
290pub fn initiate_encrypted_link(
291 session: pubky::PubkySession,
292 sender_secret_key: [u8; 32],
293 receiver_identity_public_key: &PublicKey,
294 receiver_noise_public_key: &PublicKey,
295 recovery_context: EncryptedLinkRecoveryContext,
296 outbox_client: pubky::Pubky,
297) -> Result<EncryptedLinkHandshake> {
298 debug!("initializing Encrypted Link handshake (initiator)");
299
300 let (write_path, read_path) = compute_private_payment_paths(
301 &sender_secret_key,
302 session.info().public_key(),
303 receiver_identity_public_key,
304 receiver_noise_public_key,
305 &recovery_context,
306 );
307
308 let config = pubky_noise::PubkyNoiseConfig::new_with_paths(
309 sender_secret_key,
310 0,
311 "XX",
312 session,
313 write_path,
314 read_path,
315 outbox_client,
316 )
317 .map_err(|err| PaykitError::Transport {
318 context: format!("failed to create encryptor config: {err:?}"),
319 source: anyhow::anyhow!("pubky-noise PubkyNoiseConfig::new failed: {err:?}"),
320 })?;
321
322 let encryptor = pubky_noise::PubkyNoiseEncryptor::new(
323 config.clone(),
324 sender_secret_key,
325 true,
326 receiver_identity_public_key.clone(),
327 )
328 .map_err(|err| PaykitError::Transport {
329 context: format!("failed to initialize encryptor: {err:?}"),
330 source: anyhow::anyhow!("pubky-noise PubkyNoiseEncryptor::new failed: {err:?}"),
331 })?;
332
333 debug!("handshake context initialized (initiator)");
334 Ok(EncryptedLinkHandshake {
335 encryptor,
336 remote_identity_public_key: receiver_identity_public_key.clone(),
337 remote_noise_public_key: receiver_noise_public_key.clone(),
338 recovery_context,
339 config,
340 recovery_attempts: 0,
341 max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
342 })
343}
344
345#[instrument(skip(session, receiver_secret_key, outbox_client))]
358pub fn accept_encrypted_link(
359 session: pubky::PubkySession,
360 receiver_secret_key: [u8; 32],
361 sender_identity_public_key: &PublicKey,
362 sender_noise_public_key: &PublicKey,
363 recovery_context: EncryptedLinkRecoveryContext,
364 outbox_client: pubky::Pubky,
365) -> Result<EncryptedLinkHandshake> {
366 debug!("initializing Encrypted Link handshake (responder)");
367
368 let (write_path, read_path) = compute_private_payment_paths(
369 &receiver_secret_key,
370 session.info().public_key(),
371 sender_identity_public_key,
372 sender_noise_public_key,
373 &recovery_context,
374 );
375
376 let config = pubky_noise::PubkyNoiseConfig::new_with_paths(
377 receiver_secret_key,
378 0,
379 "XX",
380 session,
381 write_path,
382 read_path,
383 outbox_client,
384 )
385 .map_err(|err| PaykitError::Transport {
386 context: format!("failed to create encryptor config: {err:?}"),
387 source: anyhow::anyhow!("pubky-noise PubkyNoiseConfig::new failed: {err:?}"),
388 })?;
389
390 let encryptor = pubky_noise::PubkyNoiseEncryptor::new(
391 config.clone(),
392 receiver_secret_key,
393 false,
394 sender_identity_public_key.clone(),
395 )
396 .map_err(|err| PaykitError::Transport {
397 context: format!("failed to initialize encryptor: {err:?}"),
398 source: anyhow::anyhow!("pubky-noise PubkyNoiseEncryptor::new failed: {err:?}"),
399 })?;
400
401 debug!("handshake context initialized (responder)");
402 Ok(EncryptedLinkHandshake {
403 encryptor,
404 remote_identity_public_key: sender_identity_public_key.clone(),
405 remote_noise_public_key: sender_noise_public_key.clone(),
406 recovery_context,
407 config,
408 recovery_attempts: 0,
409 max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
410 })
411}
412
413#[instrument(skip(handshake))]
424pub async fn advance_handshake(
425 mut handshake: EncryptedLinkHandshake,
426) -> std::result::Result<HandshakeProgress, HandshakeAdvanceError> {
427 if handshake.encryptor.is_handshake_complete() {
429 return finish_handshake(handshake).map_err(Into::into);
430 }
431
432 match handshake.encryptor.handle_handshake().await {
434 Ok(pubky_noise::HandshakeResult::Pending)
435 if handshake.encryptor.is_handshake_complete() =>
436 {
437 finish_handshake(handshake).map_err(Into::into)
438 }
439 Ok(pubky_noise::HandshakeResult::Pending) => {
440 debug!("handshake step pending (waiting for counterparty)");
441 handshake.recovery_attempts = 0;
442 Ok(HandshakeProgress::Pending(handshake))
443 }
444 Ok(pubky_noise::HandshakeResult::Terminal) => {
445 debug!("handshake terminal, transitioning to transport");
446 finish_handshake(handshake).map_err(Into::into)
447 }
448 Err(
449 err @ (pubky_noise::PubkyNoiseError::HomeserverResponseError
450 | pubky_noise::PubkyNoiseError::HomeserverHttpError { .. }),
451 ) => Err(HandshakeAdvanceError {
452 error: handshake_error("handshake step failed", err),
453 retry_handshake: Some(Box::new(handshake)),
454 }),
455 Err(pubky_noise::PubkyNoiseError::HomeserverWriteError) => {
456 handshake.recovery_attempts += 1;
457
458 if handshake.recovery_attempts > handshake.max_recovery_attempts {
459 return Err(PaykitError::Transport {
460 context: format!(
461 "handshake recovery exhausted after {} consecutive attempts",
462 handshake.max_recovery_attempts,
463 ),
464 source: anyhow::anyhow!(
465 "HomeserverWriteError persisted beyond recovery limit ({})",
466 handshake.max_recovery_attempts,
467 ),
468 }
469 .into());
470 }
471
472 warn!(
473 attempts = handshake.recovery_attempts,
474 max = handshake.max_recovery_attempts,
475 "handshake write failed, attempting automatic recovery from snapshot"
476 );
477
478 let snapshot = handshake
479 .encryptor
480 .last_good_snapshot()
481 .cloned()
482 .ok_or_else(|| PaykitError::Transport {
483 context: "handshake recovery failed: missing last-good snapshot".into(),
484 source: anyhow::anyhow!(
485 "pubky-noise returned HomeserverWriteError but no recovery snapshot"
486 ),
487 })?;
488
489 let restored = pubky_noise::PubkyNoiseEncryptor::restore(
490 handshake.config.clone(),
491 snapshot,
492 handshake.remote_identity_public_key.clone(),
493 )
494 .await
495 .map_err(|err| handshake_error("handshake recovery via restore() failed", err))?;
496
497 debug!("handshake recovered successfully, returning Pending");
498 Ok(HandshakeProgress::Pending(EncryptedLinkHandshake {
499 encryptor: restored,
500 config: handshake.config,
501 remote_identity_public_key: handshake.remote_identity_public_key,
502 remote_noise_public_key: handshake.remote_noise_public_key,
503 recovery_context: handshake.recovery_context,
504 recovery_attempts: handshake.recovery_attempts,
505 max_recovery_attempts: handshake.max_recovery_attempts,
506 }))
507 }
508 Err(err) => Err(handshake_error("handshake step failed", err).into()),
509 }
510}
511
512fn handshake_error(context: &str, err: pubky_noise::PubkyNoiseError) -> PaykitError {
513 let context = format!("{context}: {err:?}");
514 let source = anyhow::anyhow!("pubky-noise handshake failed: {err:?}");
515 match err {
516 pubky_noise::PubkyNoiseError::HomeserverResponseError
517 | pubky_noise::PubkyNoiseError::HomeserverHttpError { .. }
518 | pubky_noise::PubkyNoiseError::HomeserverWriteError => {
519 PaykitError::Transport { context, source }
520 }
521 _ => PaykitError::InvalidData {
522 context,
523 source: Some(source),
524 },
525 }
526}
527
528fn finish_handshake(mut handshake: EncryptedLinkHandshake) -> Result<HandshakeProgress> {
530 let _link_id = handshake
531 .encryptor
532 .transition_transport()
533 .map_err(|err| handshake_error("failed to transition to transport mode", err))?;
534
535 debug!("Encrypted Link established");
536 Ok(HandshakeProgress::Complete(EncryptedLink::from_parts(
537 handshake.encryptor,
538 handshake.remote_identity_public_key,
539 handshake.remote_noise_public_key,
540 handshake.recovery_context,
541 handshake.config,
542 )))
543}
544
545#[instrument(skip(session, secret_key, outbox_client, snapshot))]
550pub async fn restore_encrypted_link_handshake(
551 session: pubky::PubkySession,
552 secret_key: [u8; 32],
553 remote_identity_public_key: &PublicKey,
554 outbox_client: pubky::Pubky,
555 snapshot: EncryptedLinkHandshakeSnapshot,
556) -> Result<EncryptedLinkHandshake> {
557 debug!("restoring Encrypted Link handshake from snapshot (raw params)");
558
559 let (write_path, read_path) = compute_private_payment_paths(
560 &secret_key,
561 session.info().public_key(),
562 remote_identity_public_key,
563 snapshot.remote_noise_public_key(),
564 snapshot.recovery_context(),
565 );
566
567 let config = pubky_noise::PubkyNoiseConfig::new_with_paths(
568 secret_key,
569 0,
570 "XX",
571 session,
572 write_path,
573 read_path,
574 outbox_client,
575 )
576 .map_err(|err| PaykitError::Transport {
577 context: format!("failed to create encryptor config for handshake restore: {err:?}"),
578 source: anyhow::anyhow!("pubky-noise PubkyNoiseConfig::new failed: {err:?}"),
579 })?;
580
581 restore_encrypted_link_handshake_inner(config, remote_identity_public_key, snapshot).await
582}
583
584#[instrument(skip(config, snapshot))]
593pub async fn restore_encrypted_link_handshake_from_config(
594 config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
595 remote_identity_public_key: &PublicKey,
596 snapshot: EncryptedLinkHandshakeSnapshot,
597) -> Result<EncryptedLinkHandshake> {
598 debug!("restoring Encrypted Link handshake from snapshot (existing config)");
599 restore_encrypted_link_handshake_inner(config, remote_identity_public_key, snapshot).await
600}
601
602async fn restore_encrypted_link_handshake_inner(
604 config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
605 remote_identity_public_key: &PublicKey,
606 snapshot: EncryptedLinkHandshakeSnapshot,
607) -> Result<EncryptedLinkHandshake> {
608 if snapshot.recipient() != remote_identity_public_key {
609 return Err(PaykitError::Validation(format!(
610 "remote_identity_public_key does not match snapshot recipient (remote={}, snapshot={})",
611 remote_identity_public_key,
612 snapshot.recipient(),
613 )));
614 }
615
616 let phase = snapshot.phase();
617 if !matches!(phase, pubky_noise::snow_crypto::NoisePhase::HandShake) {
618 return Err(PaykitError::Validation(format!(
619 "handshake restore requires handshake-phase snapshot, got {:?}",
620 phase,
621 )));
622 }
623
624 let remote_noise_public_key = snapshot.remote_noise_public_key().clone();
625 let recovery_context = snapshot.recovery_context().clone();
626 validate_private_payment_paths(
627 &config,
628 remote_identity_public_key,
629 &remote_noise_public_key,
630 &recovery_context,
631 )?;
632 let state = snapshot.into_state()?;
633 if state.static_secret != Some(config.pubky_root_keypair.secret_key()) {
634 return Err(PaykitError::Validation(
635 "Noise config key does not match snapshot static key".into(),
636 ));
637 }
638 let encryptor = pubky_noise::PubkyNoiseEncryptor::restore(
639 config.clone(),
640 state,
641 remote_identity_public_key.clone(),
642 )
643 .await
644 .map_err(|err| handshake_error("failed to restore Encrypted Link handshake", err))?;
645
646 debug!("Encrypted Link handshake restored successfully (recovery tuning reset to defaults)");
647
648 Ok(EncryptedLinkHandshake {
649 encryptor,
650 remote_identity_public_key: remote_identity_public_key.clone(),
651 remote_noise_public_key,
652 recovery_context,
653 config,
654 recovery_attempts: 0,
655 max_recovery_attempts: DEFAULT_MAX_RECOVERY_ATTEMPTS,
656 })
657}
658
659#[cfg(test)]
660mod tests {
661 use super::*;
662
663 #[test]
664 fn test_handshake_io_failures_remain_retryable() {
665 for error in [
666 pubky_noise::PubkyNoiseError::HomeserverResponseError,
667 pubky_noise::PubkyNoiseError::HomeserverWriteError,
668 pubky_noise::PubkyNoiseError::HomeserverHttpError { status: 401 },
669 pubky_noise::PubkyNoiseError::HomeserverHttpError { status: 403 },
670 pubky_noise::PubkyNoiseError::HomeserverHttpError { status: 408 },
671 pubky_noise::PubkyNoiseError::HomeserverHttpError { status: 429 },
672 pubky_noise::PubkyNoiseError::HomeserverHttpError { status: 500 },
673 pubky_noise::PubkyNoiseError::HomeserverHttpError { status: 503 },
674 ] {
675 assert!(matches!(
676 handshake_error("handshake failed", error),
677 PaykitError::Transport { .. }
678 ));
679 }
680 assert!(matches!(
681 handshake_error(
682 "handshake replay failed",
683 pubky_noise::PubkyNoiseError::RestoreBackupReplayError,
684 ),
685 PaykitError::InvalidData { .. }
686 ));
687 }
688}