Skip to main content

paykit_lib/encrypted_link/
snapshot.rs

1use super::EncryptedLinkRecoveryContext;
2use crate::{PaykitError, PublicKey, Result};
3
4const HANDSHAKE_CHECKPOINT_VERSION: u8 = 1;
5const HANDSHAKE_PACKET_LEN: usize = pubky_noise::snow_crypto::PUBKY_NOISE_CIPHERTEXT_LEN + 2;
6const MAX_HANDSHAKE_PATH_LEN: usize = crate::PAYKIT_PRIVATE_PATH_PREFIX.len() + 1 + 64 + 1 + 10;
7const MAX_HANDSHAKE_SNAPSHOT_LEN: usize =
8    pubky_noise::serializer::MAX_SESSION_STATE_LEN + 32 + EncryptedLinkRecoveryContext::ENCODED_LEN;
9
10struct PendingHandshakePacket {
11    destination_path: String,
12    packet: Vec<u8>,
13}
14
15fn invalid_handshake_checkpoint() -> PaykitError {
16    PaykitError::InvalidData {
17        context: "invalid Encrypted Link Handshake checkpoint".into(),
18        source: None,
19    }
20}
21
22/// Serializable snapshot of an established [`EncryptedLink`](crate::EncryptedLink).
23///
24/// Serialize with [`serialize`](Self::serialize) and restore with
25/// [`restore_encrypted_link`](crate::restore_encrypted_link). Snapshot bytes
26/// include sensitive key material and must be stored as secrets.
27pub struct EncryptedLinkSnapshot {
28    /// The underlying pubky-noise session state.
29    state: pubky_noise::serializer::PubkyNoiseSessionState,
30    /// The counterparty's public key (derived from `state.endpoint_pubkey`).
31    recipient: PublicKey,
32    /// The counterparty's identity-wide Noise public key.
33    remote_noise_public_key: PublicKey,
34    recovery_context: EncryptedLinkRecoveryContext,
35}
36
37fn recipient_from_snapshot_state(
38    state: &pubky_noise::serializer::PubkyNoiseSessionState,
39    snapshot_kind: &'static str,
40) -> Result<PublicKey> {
41    let pkarr_pk =
42        pubky::pkarr::PublicKey::try_from(state.endpoint_pubkey.as_slice()).map_err(|err| {
43            PaykitError::InvalidData {
44                context: format!(
45                    "failed to reconstruct recipient public key from {snapshot_kind}: {err}"
46                ),
47                source: Some(err.into()),
48            }
49        })?;
50    Ok(PublicKey::from(pkarr_pk))
51}
52
53fn public_key_from_bytes(bytes: &[u8], context: &'static str) -> Result<PublicKey> {
54    let pkarr_pk =
55        pubky::pkarr::PublicKey::try_from(bytes).map_err(|err| PaykitError::InvalidData {
56            context: format!("failed to reconstruct {context}: {err}"),
57            source: Some(err.into()),
58        })?;
59    Ok(PublicKey::from(pkarr_pk))
60}
61
62fn serialize_snapshot(
63    state: &pubky_noise::serializer::PubkyNoiseSessionState,
64    remote_noise_public_key: &PublicKey,
65    recovery_context: &EncryptedLinkRecoveryContext,
66) -> Vec<u8> {
67    let mut bytes = state.serialize();
68    bytes.extend_from_slice(&remote_noise_public_key.to_bytes());
69    recovery_context.append_bytes(&mut bytes, true);
70    bytes
71}
72
73fn deserialize_snapshot(
74    bytes: &[u8],
75    snapshot_kind: &'static str,
76) -> Result<(
77    pubky_noise::serializer::PubkyNoiseSessionState,
78    PublicKey,
79    EncryptedLinkRecoveryContext,
80)> {
81    let Some(state_len) = bytes
82        .len()
83        .checked_sub(32 + EncryptedLinkRecoveryContext::ENCODED_LEN)
84    else {
85        return Err(PaykitError::InvalidData {
86            context: format!("{snapshot_kind} is too short"),
87            source: None,
88        });
89    };
90    let state = deserialize_noise_state(&bytes[..state_len], snapshot_kind)?;
91    if state.serialize().len() != state_len {
92        return Err(PaykitError::InvalidData {
93            context: format!("{snapshot_kind} has an invalid length"),
94            source: None,
95        });
96    }
97    let remote_noise_public_key =
98        public_key_from_bytes(&bytes[state_len..state_len + 32], "remote Noise public key")?;
99    let recovery_context = EncryptedLinkRecoveryContext::from_bytes(&bytes[state_len + 32..])?;
100    Ok((state, remote_noise_public_key, recovery_context))
101}
102
103impl std::fmt::Debug for EncryptedLinkSnapshot {
104    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105        f.debug_struct("EncryptedLinkSnapshot")
106            .field("recipient", &self.recipient)
107            .field("remote_noise_public_key", &self.remote_noise_public_key)
108            .finish_non_exhaustive()
109    }
110}
111
112impl EncryptedLinkSnapshot {
113    pub(super) fn from_state(
114        state: pubky_noise::serializer::PubkyNoiseSessionState,
115        recipient: PublicKey,
116        remote_noise_public_key: PublicKey,
117        recovery_context: EncryptedLinkRecoveryContext,
118    ) -> Self {
119        Self {
120            state,
121            recipient,
122            remote_noise_public_key,
123            recovery_context,
124        }
125    }
126
127    pub(super) fn phase(&self) -> pubky_noise::snow_crypto::NoisePhase {
128        self.state.phase
129    }
130
131    pub(super) fn into_state(self) -> pubky_noise::serializer::PubkyNoiseSessionState {
132        self.state
133    }
134
135    /// Serialize to a compact binary format for durable storage.
136    ///
137    /// The output contains the `pubky-noise` session state followed by the
138    /// counterparty's 32-byte Noise public key and 72-byte recovery context.
139    pub fn serialize(&self) -> Vec<u8> {
140        serialize_snapshot(
141            &self.state,
142            &self.remote_noise_public_key,
143            &self.recovery_context,
144        )
145    }
146
147    /// Deserialize from bytes previously produced by [`serialize`](Self::serialize).
148    ///
149    /// Returns [`PaykitError::InvalidData`] if the bytes are malformed or the
150    /// embedded public key cannot be reconstructed.
151    pub fn deserialize(bytes: &[u8]) -> Result<Self> {
152        let (state, remote_noise_public_key, recovery_context) =
153            deserialize_snapshot(bytes, "Encrypted Link snapshot")?;
154        let recipient = recipient_from_snapshot_state(&state, "Encrypted Link snapshot")?;
155
156        Ok(Self {
157            state,
158            recipient,
159            remote_noise_public_key,
160            recovery_context,
161        })
162    }
163
164    /// Access the counterparty's public key embedded in the snapshot.
165    pub fn recipient(&self) -> &PublicKey {
166        &self.recipient
167    }
168
169    /// Access the counterparty's identity-wide Noise public key.
170    pub fn remote_noise_public_key(&self) -> &PublicKey {
171        &self.remote_noise_public_key
172    }
173
174    /// Recovery attempts used to derive this connection's stream paths.
175    pub fn recovery_context(&self) -> &EncryptedLinkRecoveryContext {
176        &self.recovery_context
177    }
178
179    /// Stable identifier of the completed Noise handshake, unchanged by transport messages.
180    pub fn link_id(&self) -> Option<[u8; 32]> {
181        self.state.link_id
182    }
183
184    /// Check whether the next private message slot exists without restoring Noise.
185    ///
186    /// This is an advisory HEAD request. It does not authenticate a message or
187    /// advance the snapshot. Before receiving, reload the authoritative state
188    /// and use the ordinary locked receive workflow. Session authorization and
189    /// key rotation remain the caller's responsibility.
190    pub async fn has_pending_private_application_message(
191        &self,
192        public_storage: &pubky::PublicStorage,
193        local_identity_public_key: &PublicKey,
194        local_noise_secret_key: &[u8; 32],
195    ) -> Result<bool> {
196        if self.state.phase != pubky_noise::snow_crypto::NoisePhase::Transport
197            || self.state.static_secret.as_ref() != Some(local_noise_secret_key)
198            || self.state.read_counter >= u32::MAX - 1
199            || self.state.receiving_nonce >= u64::MAX - 1
200        {
201            return Err(PaykitError::Validation(
202                "snapshot cannot receive with the supplied Noise key".into(),
203            ));
204        }
205        let (_, read_path) = super::paths::compute_private_payment_paths(
206            local_noise_secret_key,
207            local_identity_public_key,
208            &self.recipient,
209            &self.remote_noise_public_key,
210            &self.recovery_context,
211        );
212        public_storage
213            .exists(format!(
214                "{}/{}/{}",
215                self.recipient, read_path, self.state.read_counter
216            ))
217            .await
218            .map_err(|err| PaykitError::Transport {
219                context: "failed to check private message availability".into(),
220                source: err.into(),
221            })
222    }
223}
224
225fn deserialize_noise_state(
226    bytes: &[u8],
227    snapshot_kind: &'static str,
228) -> Result<pubky_noise::serializer::PubkyNoiseSessionState> {
229    pubky_noise::serializer::PubkyNoiseSessionState::deserialize(bytes).map_err(|err| {
230        PaykitError::InvalidData {
231            context: format!("failed to deserialize {snapshot_kind}: {err:?}"),
232            source: None,
233        }
234    })
235}
236
237/// Serializable snapshot of an in-progress
238/// [`EncryptedLinkHandshake`](crate::EncryptedLinkHandshake).
239///
240/// Serialize with [`serialize`](Self::serialize) and restore with
241/// [`restore_encrypted_link_handshake`](crate::restore_encrypted_link_handshake).
242/// Snapshot bytes include sensitive key material and must be stored as secrets.
243pub struct EncryptedLinkHandshakeSnapshot {
244    /// The underlying pubky-noise session state.
245    state: pubky_noise::serializer::PubkyNoiseSessionState,
246    /// The counterparty's public key (derived from `state.endpoint_pubkey`).
247    recipient: PublicKey,
248    /// The counterparty's identity-wide Noise public key.
249    remote_noise_public_key: PublicKey,
250    recovery_context: EncryptedLinkRecoveryContext,
251    pending: Option<PendingHandshakePacket>,
252}
253
254impl std::fmt::Debug for EncryptedLinkHandshakeSnapshot {
255    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        f.debug_struct("EncryptedLinkHandshakeSnapshot")
257            .field("recipient", &self.recipient)
258            .field("remote_noise_public_key", &self.remote_noise_public_key)
259            .finish_non_exhaustive()
260    }
261}
262
263impl EncryptedLinkHandshakeSnapshot {
264    pub(super) fn from_state(
265        state: pubky_noise::serializer::PubkyNoiseSessionState,
266        recipient: PublicKey,
267        remote_noise_public_key: PublicKey,
268        recovery_context: EncryptedLinkRecoveryContext,
269    ) -> Self {
270        Self {
271            state,
272            recipient,
273            remote_noise_public_key,
274            recovery_context,
275            pending: None,
276        }
277    }
278
279    pub(super) fn phase(&self) -> pubky_noise::snow_crypto::NoisePhase {
280        self.state.phase
281    }
282
283    pub(super) fn into_state(self) -> Result<pubky_noise::serializer::PubkyNoiseSessionState> {
284        if self.pending.is_some() {
285            return Err(PaykitError::Validation(
286                "handshake publication must be acknowledged before restoration".into(),
287            ));
288        }
289        Ok(self.state)
290    }
291
292    /// Serialize to a compact binary format for durable storage.
293    ///
294    /// The versioned envelope contains a length-delimited snapshot and at most
295    /// one destination path with its exact, complete handshake packet.
296    pub fn serialize(&self) -> Vec<u8> {
297        let snapshot = serialize_snapshot(
298            &self.state,
299            &self.remote_noise_public_key,
300            &self.recovery_context,
301        );
302        let mut bytes = vec![HANDSHAKE_CHECKPOINT_VERSION];
303        bytes.extend_from_slice(&(snapshot.len() as u16).to_be_bytes());
304        bytes.extend_from_slice(&snapshot);
305        let path = self
306            .pending
307            .as_ref()
308            .map_or("", |pending| pending.destination_path.as_str());
309        bytes.extend_from_slice(&(path.len() as u16).to_be_bytes());
310        bytes.extend_from_slice(path.as_bytes());
311        if let Some(pending) = &self.pending {
312            bytes.extend_from_slice(&pending.packet);
313        }
314        bytes
315    }
316
317    /// Deserialize from bytes previously produced by [`serialize`](Self::serialize).
318    ///
319    /// Returns [`PaykitError::InvalidData`] if the bytes are malformed or the
320    /// embedded public key cannot be reconstructed.
321    pub fn deserialize(bytes: &[u8]) -> Result<Self> {
322        if bytes.len() < 5
323            || bytes.len()
324                > 5 + MAX_HANDSHAKE_SNAPSHOT_LEN + MAX_HANDSHAKE_PATH_LEN + HANDSHAKE_PACKET_LEN
325            || bytes[0] != HANDSHAKE_CHECKPOINT_VERSION
326        {
327            return Err(invalid_handshake_checkpoint());
328        }
329        let snapshot_len = u16::from_be_bytes([bytes[1], bytes[2]]) as usize;
330        if snapshot_len > MAX_HANDSHAKE_SNAPSHOT_LEN || bytes.len() < 5 + snapshot_len {
331            return Err(invalid_handshake_checkpoint());
332        }
333        let path_offset = 3 + snapshot_len;
334        let path_len = u16::from_be_bytes([bytes[path_offset], bytes[path_offset + 1]]) as usize;
335        let tail = &bytes[path_offset + 2..];
336        if path_len > MAX_HANDSHAKE_PATH_LEN
337            || tail.len()
338                != path_len
339                    + if path_len == 0 {
340                        0
341                    } else {
342                        HANDSHAKE_PACKET_LEN
343                    }
344        {
345            return Err(invalid_handshake_checkpoint());
346        }
347        let (state, remote_noise_public_key, recovery_context) =
348            deserialize_snapshot(&bytes[3..path_offset], "Encrypted Link Handshake snapshot")?;
349        if state.phase != pubky_noise::snow_crypto::NoisePhase::HandShake {
350            return Err(invalid_handshake_checkpoint());
351        }
352        let recipient = recipient_from_snapshot_state(&state, "Encrypted Link Handshake snapshot")?;
353        let mut snapshot = Self {
354            state,
355            recipient,
356            remote_noise_public_key,
357            recovery_context,
358            pending: None,
359        };
360        if path_len != 0 {
361            let path = std::str::from_utf8(&tail[..path_len])
362                .map_err(|_| invalid_handshake_checkpoint())?;
363            snapshot.validate_pending_packet(path, &tail[path_len..])?;
364            snapshot.pending = Some(PendingHandshakePacket {
365                destination_path: path.to_owned(),
366                packet: tail[path_len..].to_vec(),
367            });
368        }
369        Ok(snapshot)
370    }
371
372    fn validate_pending_packet(&self, path: &str, packet: &[u8]) -> Result<()> {
373        use pubky_noise::snow_crypto::{full_handshake_actions, HandshakeAction};
374        let slot = self
375            .state
376            .counter
377            .checked_sub(1)
378            .ok_or_else(invalid_handshake_checkpoint)?;
379        let (directory, suffix) = path
380            .rsplit_once('/')
381            .ok_or_else(invalid_handshake_checkpoint)?;
382        let hash = directory
383            .strip_prefix(crate::PAYKIT_PRIVATE_PATH_PREFIX)
384            .and_then(|path| path.strip_prefix('/'))
385            .ok_or_else(invalid_handshake_checkpoint)?;
386        if hash.len() != 64
387            || !hash
388                .bytes()
389                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
390            || suffix != slot.to_string()
391            || full_handshake_actions(self.state.pattern, self.state.initiator)
392                .map_err(|_| invalid_handshake_checkpoint())?
393                .get(slot as usize)
394                != Some(&HandshakeAction::Write)
395            || packet.len() != HANDSHAKE_PACKET_LEN
396        {
397            return Err(invalid_handshake_checkpoint());
398        }
399        let payload_len = u16::from_be_bytes([packet[0], packet[1]]) as usize;
400        if payload_len == 0
401            || payload_len > HANDSHAKE_PACKET_LEN - 2
402            || packet[2 + payload_len..].iter().any(|byte| *byte != 0)
403        {
404            return Err(invalid_handshake_checkpoint());
405        }
406        Ok(())
407    }
408
409    /// Attach one exact Noise-prepared packet to its post-write snapshot.
410    ///
411    /// Persist the returned checkpoint before publication. Session authorization
412    /// and key rotation remain the caller's responsibility.
413    pub(super) fn with_pending_publication(
414        mut self,
415        local_identity: &PublicKey,
416        destination_path: String,
417        packet: Vec<u8>,
418    ) -> Result<Self> {
419        if self.pending.is_some() {
420            return Err(invalid_handshake_checkpoint());
421        }
422        self.validate_pending_packet(&destination_path, &packet)?;
423        self.pending = Some(PendingHandshakePacket {
424            destination_path,
425            packet,
426        });
427        let secret = self
428            .state
429            .static_secret
430            .ok_or_else(invalid_handshake_checkpoint)?;
431        self.pending_publication(local_identity, &secret)?;
432        Ok(self)
433    }
434
435    /// Read pending bytes only after checking the local key and identity-bound path.
436    ///
437    /// This does not authorize publication: the caller must validate current
438    /// authorization, recovery context, source checkpoint and lease atomically.
439    pub fn pending_publication(
440        &self,
441        local_identity: &PublicKey,
442        local_noise_secret: &[u8; 32],
443    ) -> Result<Option<(&str, &[u8])>> {
444        if self.state.static_secret.as_ref() != Some(local_noise_secret) {
445            return Err(PaykitError::Validation(
446                "handshake checkpoint Noise key mismatch".into(),
447            ));
448        }
449        let Some(pending) = &self.pending else {
450            return Ok(None);
451        };
452        let (write_path, _) = super::paths::compute_private_payment_paths(
453            local_noise_secret,
454            local_identity,
455            &self.recipient,
456            &self.remote_noise_public_key,
457            &self.recovery_context,
458        );
459        if pending.destination_path != format!("{write_path}/{}", self.state.counter - 1) {
460            return Err(PaykitError::Validation(
461                "handshake publication identity path mismatch".into(),
462            ));
463        }
464        Ok(Some((&pending.destination_path, &pending.packet)))
465    }
466
467    /// Clear only the exact published packet; conditionally persist this result
468    /// against the original pending checkpoint before advancing or exposing a link.
469    pub fn acknowledge_publication(
470        mut self,
471        destination_path: &str,
472        packet: &[u8],
473    ) -> Result<Self> {
474        if !self.pending.as_ref().is_some_and(|pending| {
475            pending.destination_path == destination_path && pending.packet == packet
476        }) {
477            return Err(PaykitError::Validation(
478                "handshake publication acknowledgement mismatch".into(),
479            ));
480        }
481        self.pending = None;
482        Ok(self)
483    }
484
485    /// Access the counterparty's public key embedded in the snapshot.
486    pub fn recipient(&self) -> &PublicKey {
487        &self.recipient
488    }
489
490    /// Access the counterparty's identity-wide Noise public key.
491    pub fn remote_noise_public_key(&self) -> &PublicKey {
492        &self.remote_noise_public_key
493    }
494
495    /// Recovery attempts used to derive this handshake's stream paths.
496    pub fn recovery_context(&self) -> &EncryptedLinkRecoveryContext {
497        &self.recovery_context
498    }
499
500    /// Return the next remote handshake resource without restoring Noise.
501    ///
502    /// `None` means local work is required, not that the handshake is idle. The
503    /// returned path includes the counterparty identity and can be checked with
504    /// `PublicStorage::exists` after validating authorization and recovery state.
505    /// This advisory lookup neither authenticates a transcript nor advances it.
506    /// Before advancing, reload authoritative state under the ordinary lease.
507    /// Session authorization and key rotation remain the caller's responsibility.
508    pub fn next_handshake_read_path(
509        &self,
510        local_identity_public_key: &PublicKey,
511        local_noise_secret_key: &[u8; 32],
512    ) -> Result<Option<String>> {
513        if self.state.phase != pubky_noise::snow_crypto::NoisePhase::HandShake
514            || self.state.static_secret.as_ref() != Some(local_noise_secret_key)
515        {
516            return Err(PaykitError::Validation(
517                "snapshot cannot advance with the supplied Noise key".into(),
518            ));
519        }
520        if self.pending.is_some() {
521            return Ok(None);
522        }
523        let slot =
524            self.state
525                .next_handshake_read_slot()
526                .map_err(|err| PaykitError::InvalidData {
527                    context: format!("invalid Encrypted Link Handshake cursor: {err:?}"),
528                    source: None,
529                })?;
530        let Some(slot) = slot else {
531            return Ok(None);
532        };
533        let (_, read_path) = super::paths::compute_private_payment_paths(
534            local_noise_secret_key,
535            local_identity_public_key,
536            &self.recipient,
537            &self.remote_noise_public_key,
538            &self.recovery_context,
539        );
540        Ok(Some(format!("{}/{read_path}/{slot}", self.recipient)))
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use pubky_noise::{
548        serializer::PubkyNoiseSessionState,
549        snow_crypto::{HandshakePattern, NoisePhase, NoiseStep},
550    };
551
552    fn checkpoint(final_write: bool) -> EncryptedLinkHandshakeSnapshot {
553        let recipient = pubky::Keypair::from_secret(&[4; 32]).public_key();
554        EncryptedLinkHandshakeSnapshot::from_state(
555            PubkyNoiseSessionState {
556                version: pubky_noise::serializer::SESSION_STATE_VERSION,
557                phase: NoisePhase::HandShake,
558                pattern: HandshakePattern::PatternXX,
559                initiator: true,
560                ephemeral_secret: [1; 32],
561                static_secret: Some([2; 32]),
562                counter: if final_write { 3 } else { 1 },
563                noise_step: if final_write {
564                    NoiseStep::Final
565                } else {
566                    NoiseStep::StepTwo
567                },
568                sub_step_index: 0,
569                handshake_hash: Some([3; 32]),
570                link_id: None,
571                sending_nonce: 0,
572                receiving_nonce: 0,
573                write_counter: 0,
574                read_counter: 0,
575                endpoint_pubkey: recipient.to_bytes(),
576                handshake_messages: if final_write {
577                    vec![vec![5; 96]]
578                } else {
579                    Vec::new()
580                },
581            },
582            recipient,
583            pubky::Keypair::from_secret(&[5; 32]).public_key(),
584            EncryptedLinkRecoveryContext::default(),
585        )
586    }
587
588    fn pending(final_write: bool) -> (EncryptedLinkHandshakeSnapshot, PublicKey, String, Vec<u8>) {
589        let snapshot = checkpoint(final_write);
590        let local = pubky::Keypair::from_secret(&[6; 32]).public_key();
591        let (path, _) = super::super::paths::compute_private_payment_paths(
592            &[2; 32],
593            &local,
594            snapshot.recipient(),
595            snapshot.remote_noise_public_key(),
596            snapshot.recovery_context(),
597        );
598        let path = format!("{path}/{}", snapshot.state.counter - 1);
599        let mut packet = vec![0; HANDSHAKE_PACKET_LEN];
600        packet[1] = 32;
601        packet[2..34].fill(7);
602        (
603            snapshot
604                .with_pending_publication(&local, path.clone(), packet.clone())
605                .unwrap(),
606            local,
607            path,
608            packet,
609        )
610    }
611
612    #[test]
613    fn test_handshake_checkpoint_keeps_exact_publication_until_acknowledged() {
614        for final_write in [false, true] {
615            let plain = checkpoint(final_write).serialize();
616            let (snapshot, local, path, packet) = pending(final_write);
617            let bytes = snapshot.serialize();
618            let load = || EncryptedLinkHandshakeSnapshot::deserialize(&bytes).unwrap();
619            assert_eq!(load().serialize(), bytes);
620            assert_eq!(
621                snapshot.pending_publication(&local, &[2; 32]).unwrap(),
622                Some((path.as_str(), packet.as_slice()))
623            );
624            assert!(snapshot
625                .next_handshake_read_path(&local, &[2; 32])
626                .unwrap()
627                .is_none());
628            assert!(load().into_state().is_err());
629            for (wrong_path, wrong_packet) in [
630                (path.as_str(), &[0; HANDSHAKE_PACKET_LEN][..]),
631                ("wrong-path", packet.as_slice()),
632            ] {
633                assert!(load()
634                    .acknowledge_publication(wrong_path, wrong_packet)
635                    .is_err());
636            }
637            assert!(load()
638                .with_pending_publication(&local, path.clone(), packet.clone())
639                .is_err());
640            assert!(snapshot.pending_publication(&local, &[9; 32]).is_err());
641            assert!(snapshot
642                .pending_publication(
643                    &pubky::Keypair::from_secret(&[9; 32]).public_key(),
644                    &[2; 32]
645                )
646                .is_err());
647            let acknowledged = load().acknowledge_publication(&path, &packet).unwrap();
648            assert_eq!(acknowledged.serialize(), plain);
649            assert_eq!(
650                acknowledged.into_state().unwrap().phase,
651                NoisePhase::HandShake
652            );
653        }
654    }
655
656    #[test]
657    fn test_handshake_checkpoint_rejects_malformed_and_unbounded_envelopes() {
658        let (mut snapshot, _, path, packet) = pending(false);
659        let bytes = snapshot.serialize();
660        let path_offset = 3 + u16::from_be_bytes([bytes[1], bytes[2]]) as usize;
661        for end in [0, 1, 2, 3, path_offset, path_offset + 1, bytes.len() - 1] {
662            assert!(EncryptedLinkHandshakeSnapshot::deserialize(&bytes[..end]).is_err());
663        }
664        for mutation in [
665            "version",
666            "state-length",
667            "path-length",
668            "path",
669            "hash",
670            "slot",
671            "length",
672            "empty",
673            "padding",
674            "trailing",
675            "oversized",
676            "transport",
677        ] {
678            let mut malformed = bytes.clone();
679            let packet_offset = bytes.len() - HANDSHAKE_PACKET_LEN;
680            match mutation {
681                "version" => malformed[0] += 1,
682                "state-length" => malformed[1..3].fill(255),
683                "path-length" => malformed[path_offset..path_offset + 2].fill(255),
684                "path" => malformed[path_offset + 2] = 255,
685                "hash" => {
686                    malformed[path_offset + 3 + crate::PAYKIT_PRIVATE_PATH_PREFIX.len()] = b'/'
687                }
688                "slot" => malformed[packet_offset - 1] = b'1',
689                "length" => malformed[packet_offset..packet_offset + 2].fill(255),
690                "empty" => malformed[packet_offset..packet_offset + 2].fill(0),
691                "padding" => *malformed.last_mut().unwrap() = 1,
692                "trailing" => malformed.push(0),
693                "oversized" => malformed.resize(
694                    6 + MAX_HANDSHAKE_SNAPSHOT_LEN + MAX_HANDSHAKE_PATH_LEN + HANDSHAKE_PACKET_LEN,
695                    0,
696                ),
697                "transport" => malformed[4] = 1,
698                _ => unreachable!(),
699            }
700            assert!(
701                EncryptedLinkHandshakeSnapshot::deserialize(&malformed).is_err(),
702                "{mutation}"
703            );
704        }
705        snapshot.state.counter = 2;
706        snapshot.state.sub_step_index = 1;
707        snapshot.state.handshake_messages = vec![vec![5; 96]];
708        let directory = path.rsplit_once('/').unwrap().0;
709        assert!(snapshot
710            .validate_pending_packet(&format!("{directory}/1"), &packet)
711            .is_err());
712    }
713}