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
22pub struct EncryptedLinkSnapshot {
28 state: pubky_noise::serializer::PubkyNoiseSessionState,
30 recipient: PublicKey,
32 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 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 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 pub fn recipient(&self) -> &PublicKey {
166 &self.recipient
167 }
168
169 pub fn remote_noise_public_key(&self) -> &PublicKey {
171 &self.remote_noise_public_key
172 }
173
174 pub fn recovery_context(&self) -> &EncryptedLinkRecoveryContext {
176 &self.recovery_context
177 }
178
179 pub fn link_id(&self) -> Option<[u8; 32]> {
181 self.state.link_id
182 }
183
184 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
237pub struct EncryptedLinkHandshakeSnapshot {
244 state: pubky_noise::serializer::PubkyNoiseSessionState,
246 recipient: PublicKey,
248 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 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 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 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 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 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 pub fn recipient(&self) -> &PublicKey {
487 &self.recipient
488 }
489
490 pub fn remote_noise_public_key(&self) -> &PublicKey {
492 &self.remote_noise_public_key
493 }
494
495 pub fn recovery_context(&self) -> &EncryptedLinkRecoveryContext {
497 &self.recovery_context
498 }
499
500 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}