Skip to main content

paykit_lib/encrypted_link/
handshake.rs

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
14/// Default maximum number of consecutive automatic recovery attempts before
15/// [`advance_handshake`] gives up and returns an error.
16///
17/// Override per-handshake via [`EncryptedLinkHandshake::set_max_recovery_attempts`].
18pub const DEFAULT_MAX_RECOVERY_ATTEMPTS: u32 = 3;
19
20/// Handle to an in-progress Noise handshake.
21///
22/// Created by [`initiate_encrypted_link`] (initiator) or
23/// [`accept_encrypted_link`] (responder). Drive the handshake forward by
24/// repeatedly calling [`advance_handshake`] until it returns
25/// [`HandshakeProgress::Complete`].
26///
27/// The caller owns polling, timeouts, and backoff. Homeserver write failures are
28/// automatically recovered up to
29/// [`DEFAULT_MAX_RECOVERY_ATTEMPTS`] unless overridden.
30pub struct EncryptedLinkHandshake {
31    /// The Noise session manager in handshake mode.
32    encryptor: pubky_noise::PubkyNoiseEncryptor,
33    /// The counterparty's Pubky identity key, used for homeserver reads.
34    remote_identity_public_key: PublicKey,
35    /// The counterparty's identity-wide Noise key, used for pairwise paths.
36    remote_noise_public_key: PublicKey,
37    recovery_context: EncryptedLinkRecoveryContext,
38    /// Shared Noise configuration needed for snapshot-based recovery.
39    config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
40    /// Number of consecutive recovery attempts so far.
41    recovery_attempts: u32,
42    /// Maximum consecutive recovery attempts before giving up.
43    max_recovery_attempts: u32,
44}
45
46/// A failed handshake advance, optionally retaining unchanged read-retry state.
47///
48/// Only a transport read failure preserves the handle. This does not mean the
49/// failure is transient: retry policy, deadlines, and session authorization
50/// remain the caller's responsibility. Other failures do not expose potentially
51/// mutated handshake state. Debug output never includes the retained handle.
52pub struct HandshakeAdvanceError {
53    error: PaykitError,
54    retry_handshake: Option<Box<EncryptedLinkHandshake>>,
55}
56
57impl HandshakeAdvanceError {
58    /// Inspect the failure without consuming its optional retry handle.
59    pub fn error(&self) -> &PaykitError {
60        &self.error
61    }
62
63    /// Take the failure and, only for an unchanged transport read, its retry handle.
64    /// Pass `*handshake` back to [`advance_handshake`] after deciding to retry.
65    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/// One locally prepared handshake step, including its durable pending output.
101/// Persist the snapshot before publication; session authorization, capability
102/// scope, and key rotation remain the caller's responsibility.
103#[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    /// Whether cryptography completed, not whether publication is acknowledged.
112    pub fn is_handshake_complete(&self) -> bool {
113        self.handshake_complete
114    }
115
116    /// Authenticated remote static key, to compare with signed authorization.
117    pub fn remote_static_public_key(&self) -> Option<&[u8]> {
118        self.remote_static_public_key.as_deref()
119    }
120
121    /// Consume the preparation into the checkpoint that must be persisted.
122    pub fn into_snapshot(self) -> EncryptedLinkHandshakeSnapshot {
123        self.snapshot
124    }
125}
126
127impl EncryptedLinkHandshake {
128    /// Fetch at most one bounded packet and prepare one existing Noise step.
129    /// Returns `None` only for an absent required packet (404/410). No publication
130    /// occurs. The caller must validate the source checkpoint before applying
131    /// either the result or an error, and persist pending output before sending.
132    /// Session authorization, capability scope, and rotation are caller-owned.
133    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    /// Complete a restored handshake without any reads or writes.
193    /// An incomplete handshake is returned unchanged. Restore only an
194    /// acknowledged checkpoint; authorization and key rotation are caller-owned.
195    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    /// X25519 static key authenticated by the completed Noise handshake.
204    /// Returns `None` while the handshake is incomplete.
205    /// The caller must authenticate this key before trusting the completed link.
206    pub fn remote_static_public_key(&self) -> Option<&[u8]> {
207        self.encryptor.remote_static_public_key()
208    }
209
210    /// Set the maximum number of consecutive automatic recovery attempts
211    /// before [`advance_handshake`] gives up and returns
212    /// an error whose underlying cause is [`PaykitError::Transport`].
213    ///
214    /// Default: [`DEFAULT_MAX_RECOVERY_ATTEMPTS`] (3).
215    pub fn set_max_recovery_attempts(&mut self, max: u32) -> &mut Self {
216        self.max_recovery_attempts = max;
217        self
218    }
219
220    /// Capture the current handshake state as a serializable snapshot.
221    ///
222    /// Snapshot bytes include sensitive key material and must be stored as
223    /// secrets.
224    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    /// Serialize the current handshake state to bytes for persistence.
239    ///
240    /// Convenience method equivalent to `self.snapshot()?.serialize()`.
241    pub fn serialize(&self) -> Result<Vec<u8>> {
242        Ok(self.snapshot()?.serialize())
243    }
244
245    /// Access the shared Noise configuration for this handshake.
246    ///
247    /// Useful for passing to [`restore_encrypted_link_handshake_from_config`]
248    /// when performing in-process recovery without an app restart.
249    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
264/// Result of a single [`advance_handshake`] step.
265pub enum HandshakeProgress {
266    /// Handshake is still in progress. The counterparty may not have written
267    /// their next message yet. Pass the returned handle back to
268    /// [`advance_handshake`] after a caller-chosen delay.
269    Pending(EncryptedLinkHandshake),
270
271    /// Handshake completed successfully. The [`EncryptedLink`] is ready to send
272    /// and receive Private Application Messages.
273    Complete(EncryptedLink),
274}
275
276/// Initiates a Noise XX Encrypted Link Handshake with a counterparty
277/// (initiator role).
278///
279/// `sender_secret_key` is the local Noise secret, not the Pubky identity secret.
280/// `receiver_identity_public_key` selects the counterparty's homeserver, while
281/// `receiver_noise_public_key` is its authorized Ed25519 routing key.
282/// Authenticate [`EncryptedLink::remote_static_public_key`] against the signed
283/// X25519 key before using the completed link; routing keys alone do not authenticate it.
284/// `recovery_context` selects the current pair of recovery attempts; both peers
285/// must use the same IDs in opposite local/remote order.
286/// Session creation, capability scope, and key rotation remain the caller's responsibility.
287///
288/// Call [`advance_handshake`] until it returns [`HandshakeProgress::Complete`].
289#[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/// Accepts a Noise XX Encrypted Link Handshake from a counterparty
346/// (responder role).
347///
348/// `receiver_secret_key` is the local Noise secret, not the Pubky identity secret.
349/// `sender_identity_public_key` selects the counterparty's homeserver, while
350/// `sender_noise_public_key` is its authorized Ed25519 routing key.
351/// Authenticate [`EncryptedLink::remote_static_public_key`] against the signed
352/// X25519 key before using the completed link; routing keys alone do not authenticate it.
353/// `recovery_context` must mirror the initiator's current recovery attempt IDs.
354/// Session creation, capability scope, and key rotation remain the caller's responsibility.
355///
356/// Call [`advance_handshake`] until it returns [`HandshakeProgress::Complete`].
357#[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/// Advances the handshake by one step.
414///
415/// This is polling-safe: calling it when the counterparty has not
416/// written their next message yet returns [`HandshakeProgress::Pending`] without
417/// corrupting internal state. Homeserver write failures are automatically
418/// recovered from the pre-mutation snapshot until the recovery limit is reached.
419/// Transport read failures return an error retaining the unchanged handle via
420/// [`HandshakeAdvanceError::into_parts`]; they are not successful Pending steps.
421/// Session creation, capability scope, key rotation, deadlines, and retry timing
422/// remain the caller's responsibility.
423#[instrument(skip(handshake))]
424pub async fn advance_handshake(
425    mut handshake: EncryptedLinkHandshake,
426) -> std::result::Result<HandshakeProgress, HandshakeAdvanceError> {
427    // Check whether the handshake has already finished.
428    if handshake.encryptor.is_handshake_complete() {
429        return finish_handshake(handshake).map_err(Into::into);
430    }
431
432    // Process the next handshake step.
433    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
528/// Transitions a completed handshake into an [`EncryptedLink`].
529fn 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/// Restores an [`EncryptedLinkHandshake`] from a previously saved snapshot.
546///
547/// Restored handshakes reset recovery tuning to defaults. `remote_identity_public_key` must
548/// match `snapshot.recipient()`.
549#[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/// Restores an [`EncryptedLinkHandshake`] from a previously saved snapshot
585/// using an existing Noise configuration.
586///
587/// Restored handshakes reset recovery tuning to defaults. `remote_identity_public_key` must
588/// match `snapshot.recipient()`.
589/// The config's Noise key must match the snapshot's static key.
590/// The config paths must match the session's local Pubky identity, the remote
591/// identity, and the Noise keys; mismatches return [`PaykitError::Validation`].
592#[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
602/// Shared implementation for both handshake restore variants.
603async 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}