1use tracing::{debug, instrument};
2
3use crate::{PaykitAppId, PaykitError, PublicKey, Result};
4
5use super::{
6 paths::{compute_private_payment_paths, validate_private_payment_paths},
7 private_application_message::{self, PrivateApplicationMessage},
8 EncryptedLinkRecoveryContext, EncryptedLinkSnapshot,
9};
10
11pub struct PreparedPrivateApplicationMessageSend {
18 prepared: pubky_noise::PreparedSend,
19 resulting_snapshot: EncryptedLinkSnapshot,
20}
21
22impl PreparedPrivateApplicationMessageSend {
23 pub fn destination_path(&self) -> &str {
25 self.prepared.destination_path()
26 }
27
28 pub fn ciphertext(&self) -> &[u8] {
30 self.prepared.ciphertext()
31 }
32
33 pub fn resulting_snapshot(&self) -> &EncryptedLinkSnapshot {
35 &self.resulting_snapshot
36 }
37}
38
39impl std::fmt::Debug for PreparedPrivateApplicationMessageSend {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 f.debug_struct("PreparedPrivateApplicationMessageSend")
42 .field("destination_path", &"<redacted>")
43 .field(
44 "ciphertext",
45 &format_args!("<redacted:{} bytes>", self.ciphertext().len()),
46 )
47 .field("resulting_snapshot", &"<redacted>")
48 .finish()
49 }
50}
51
52pub struct PreparedPrivateApplicationMessageReceive {
54 prepared: pubky_noise::PreparedReceive,
55 message: PrivateApplicationMessage,
56 resulting_snapshot: EncryptedLinkSnapshot,
57}
58
59impl PreparedPrivateApplicationMessageReceive {
60 pub fn message(&self) -> &PrivateApplicationMessage {
62 &self.message
63 }
64
65 pub fn resulting_snapshot(&self) -> &EncryptedLinkSnapshot {
67 &self.resulting_snapshot
68 }
69}
70
71impl std::fmt::Debug for PreparedPrivateApplicationMessageReceive {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.debug_struct("PreparedPrivateApplicationMessageReceive")
74 .field("message", &"<redacted>")
75 .field("resulting_snapshot", &"<redacted>")
76 .finish()
77 }
78}
79
80pub struct EncryptedLink {
108 encryptor: pubky_noise::PubkyNoiseEncryptor,
110 recipient: PublicKey,
112 remote_noise_public_key: PublicKey,
114 recovery_context: EncryptedLinkRecoveryContext,
115 config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
117 max_send_retries: u32,
120}
121
122impl EncryptedLink {
123 pub fn remote_static_public_key(&self) -> Option<&[u8]> {
126 self.encryptor.remote_static_public_key()
127 }
128
129 pub(super) fn from_parts(
130 encryptor: pubky_noise::PubkyNoiseEncryptor,
131 recipient: PublicKey,
132 remote_noise_public_key: PublicKey,
133 recovery_context: EncryptedLinkRecoveryContext,
134 config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
135 ) -> Self {
136 Self {
137 encryptor,
138 recipient,
139 remote_noise_public_key,
140 recovery_context,
141 config,
142 max_send_retries: DEFAULT_MAX_SEND_RETRIES,
143 }
144 }
145
146 pub fn set_max_send_retries(&mut self, max: u32) -> &mut Self {
155 self.max_send_retries = max;
156 self
157 }
158
159 pub fn snapshot(&self) -> Result<EncryptedLinkSnapshot> {
168 Ok(EncryptedLinkSnapshot::from_state(
169 self.encryptor
170 .snapshot()
171 .map_err(|err| PaykitError::InvalidData {
172 context: format!("capture Encrypted Link snapshot: {err:?}"),
173 source: None,
174 })?,
175 self.recipient.clone(),
176 self.remote_noise_public_key.clone(),
177 self.recovery_context.clone(),
178 ))
179 }
180
181 pub fn serialize(&self) -> Result<Vec<u8>> {
185 Ok(self.snapshot()?.serialize())
186 }
187
188 pub fn config(&self) -> &std::sync::Arc<pubky_noise::PubkyNoiseConfig> {
193 &self.config
194 }
195
196 pub fn recipient(&self) -> &PublicKey {
198 &self.recipient
199 }
200
201 async fn send_private_application_message_with_context(
202 &mut self,
203 plaintext: &[u8],
204 context: &'static str,
205 ) -> Result<()> {
206 private_application_message::send_private_application_message(
207 &mut self.encryptor,
208 self.max_send_retries,
209 plaintext,
210 context,
211 )
212 .await
213 }
214
215 pub(crate) async fn send_private_payment_list_message(
216 &mut self,
217 plaintext: &[u8],
218 ) -> Result<()> {
219 self.send_private_application_message_with_context(plaintext, "Private Payment List")
220 .await
221 }
222
223 pub(crate) async fn send_receipt_access_message(&mut self, plaintext: &[u8]) -> Result<()> {
224 self.send_private_application_message_with_context(plaintext, "Receipt Access")
225 .await
226 }
227
228 pub(crate) async fn send_payment_request_message(&mut self, plaintext: &[u8]) -> Result<()> {
229 self.send_private_application_message_with_context(plaintext, "Payment Request")
230 .await
231 }
232
233 pub(crate) async fn send_payment_request_acceptance_message(
234 &mut self,
235 plaintext: &[u8],
236 ) -> Result<()> {
237 self.send_private_application_message_with_context(plaintext, "Payment Request Acceptance")
238 .await
239 }
240
241 pub(crate) async fn send_payment_request_rejection_message(
242 &mut self,
243 plaintext: &[u8],
244 ) -> Result<()> {
245 self.send_private_application_message_with_context(plaintext, "Payment Request Rejection")
246 .await
247 }
248
249 pub(crate) async fn send_payment_request_cancellation_message(
250 &mut self,
251 plaintext: &[u8],
252 ) -> Result<()> {
253 self.send_private_application_message_with_context(
254 plaintext,
255 "Payment Request Cancellation",
256 )
257 .await
258 }
259
260 pub(crate) async fn send_payment_proof_message(&mut self, plaintext: &[u8]) -> Result<()> {
261 self.send_private_application_message_with_context(plaintext, "Payment Proof")
262 .await
263 }
264
265 pub(crate) async fn send_payment_conversion_quote_message(
266 &mut self,
267 plaintext: &[u8],
268 ) -> Result<()> {
269 self.send_private_application_message_with_context(plaintext, "Payment Conversion Quote")
270 .await
271 }
272
273 pub(crate) async fn send_allowance_message(&mut self, plaintext: &[u8]) -> Result<()> {
274 self.send_private_application_message_with_context(plaintext, "Allowance Event Message")
275 .await
276 }
277
278 pub async fn send_private_application_message_json(&mut self, raw_json: &str) -> Result<()> {
301 validate_private_application_message_json(raw_json)?;
302 self.send_private_application_message_with_context(
303 raw_json.as_bytes(),
304 "Private Application Message",
305 )
306 .await
307 }
308
309 #[allow(deprecated)]
317 pub async fn retry_pending_send(&mut self) -> Result<()> {
318 self.encryptor
319 .retry_pending_send()
320 .await
321 .map_err(|err| PaykitError::Transport {
322 context: format!("retry pending private send: {err:?}"),
323 source: anyhow::anyhow!("pubky-noise retry_pending_send failed: {err:?}"),
324 })
325 }
326
327 pub fn prepare_private_application_message_json(
332 &mut self,
333 raw_json: &str,
334 ) -> Result<PreparedPrivateApplicationMessageSend> {
335 private_application_message::validate_private_application_message_size(
336 raw_json.as_bytes(),
337 "Private Application Message",
338 )?;
339 validate_private_application_message_json(raw_json)?;
340 let prepared = self
341 .encryptor
342 .prepare_send(raw_json.as_bytes())
343 .map_err(|err| PaykitError::Transport {
344 context: format!("prepare Private Application Message send: {err:?}"),
345 source: anyhow::Error::new(crate::error::NonRetryablePrivateSendError(err)),
346 })?;
347 let resulting_snapshot = EncryptedLinkSnapshot::from_state(
348 prepared.resulting_session_state().clone(),
349 self.recipient.clone(),
350 self.remote_noise_public_key.clone(),
351 self.recovery_context.clone(),
352 );
353 Ok(PreparedPrivateApplicationMessageSend {
354 prepared,
355 resulting_snapshot,
356 })
357 }
358
359 pub fn acknowledge_persisted_private_send(
361 &mut self,
362 prepared: PreparedPrivateApplicationMessageSend,
363 ) -> Result<()> {
364 self.encryptor
365 .acknowledge_persisted_send(prepared.prepared)
366 .map_err(|err| PaykitError::InvalidData {
367 context: format!("acknowledge persisted private send: {err:?}"),
368 source: None,
369 })
370 }
371
372 pub async fn publish_prepared_private_application_message(
377 &self,
378 destination_path: &str,
379 ciphertext: &[u8],
380 ) -> Result<()> {
381 let expected_prefix = format!("{}/", self.config.write_path);
382 if !destination_path.starts_with(&expected_prefix)
383 || destination_path[expected_prefix.len()..]
384 .parse::<u32>()
385 .is_err()
386 {
387 return Err(PaykitError::Validation(
388 "prepared private send destination does not belong to this Encrypted Link".into(),
389 ));
390 }
391 if ciphertext.len() != pubky_noise::snow_crypto::PUBKY_NOISE_TRANSPORT_PACKET_LEN {
392 return Err(PaykitError::InvalidData {
393 context: "prepared private send ciphertext has an invalid length".into(),
394 source: None,
395 });
396 }
397
398 let max_attempts = self.max_send_retries.saturating_add(1);
399 let mut last_error = None;
400 for attempt in 1..=max_attempts {
401 match self
402 .config
403 .local_session
404 .storage()
405 .put(destination_path, ciphertext.to_vec())
406 .await
407 {
408 Ok(_) => return Ok(()),
409 Err(err) => {
410 last_error = Some(err);
411 if attempt < max_attempts {
412 tracing::warn!(
413 attempt,
414 max_retries = self.max_send_retries,
415 "prepared private message publication failed, retrying"
416 );
417 }
418 }
419 }
420 }
421
422 Err(PaykitError::Transport {
423 context: format!(
424 "failed to publish prepared Private Application Message after {max_attempts} attempts"
425 ),
426 source: last_error
427 .expect("at least one prepared private send attempt must run")
428 .into(),
429 })
430 }
431
432 pub async fn prepare_next_private_application_message(
437 &mut self,
438 ) -> Result<Option<PreparedPrivateApplicationMessageReceive>> {
439 let path = self
440 .encryptor
441 .next_receive_path()
442 .map_err(|err| PaykitError::Transport {
443 context: format!("prepare Private Application Message receive path: {err:?}"),
444 source: anyhow::Error::new(crate::error::NonRetryablePrivateReceiveError(err)),
445 })?;
446 let response = match self.config.outbox_client.public_storage().get(path).await {
447 Ok(response) => response,
448 Err(pubky::Error::Request(pubky::errors::RequestError::Server { status, .. }))
449 if status == pubky::StatusCode::NOT_FOUND || status == pubky::StatusCode::GONE =>
450 {
451 return Ok(None)
452 }
453 Err(err) => {
454 return Err(PaykitError::Transport {
455 context: "fetch next Private Application Message ciphertext".into(),
456 source: err.into(),
457 })
458 }
459 };
460 let ciphertext = crate::pubky_routing::read_bounded_body(
462 response,
463 pubky_noise::snow_crypto::PUBKY_NOISE_TRANSPORT_PACKET_LEN,
464 "read next Private Application Message ciphertext",
465 )
466 .await?;
467 let prepared =
468 self.encryptor
469 .prepare_receive(&ciphertext)
470 .map_err(|err| PaykitError::Transport {
471 context: format!("prepare Private Application Message receive: {err:?}"),
472 source: anyhow::Error::new(crate::error::NonRetryablePrivateReceiveError(err)),
473 })?;
474 let message =
475 private_application_message::decode_private_application_message(prepared.plaintext())?;
476 let resulting_snapshot = EncryptedLinkSnapshot::from_state(
477 prepared.resulting_session_state().clone(),
478 self.recipient.clone(),
479 self.remote_noise_public_key.clone(),
480 self.recovery_context.clone(),
481 );
482 Ok(Some(PreparedPrivateApplicationMessageReceive {
483 prepared,
484 message,
485 resulting_snapshot,
486 }))
487 }
488
489 pub fn acknowledge_persisted_private_receive(
491 &mut self,
492 prepared: PreparedPrivateApplicationMessageReceive,
493 ) -> Result<()> {
494 self.encryptor
495 .acknowledge_persisted_receive(prepared.prepared)
496 .map_err(|err| PaykitError::InvalidData {
497 context: format!("acknowledge persisted private receive: {err:?}"),
498 source: None,
499 })
500 }
501
502 #[cfg(test)]
503 pub(crate) async fn send_private_application_message_for_test(
504 &mut self,
505 plaintext: &[u8],
506 ) -> Result<()> {
507 self.send_private_application_message_with_context(
508 plaintext,
509 "raw test Private Application Message",
510 )
511 .await
512 }
513
514 #[cfg(test)]
515 pub(crate) fn next_receive_path_for_test(&self) -> Result<String> {
516 self.encryptor
517 .next_receive_path()
518 .map_err(|err| PaykitError::Transport {
519 context: format!("next_receive_path: {err:?}"),
520 source: anyhow::anyhow!("pubky-noise next_receive_path failed: {err:?}"),
521 })
522 }
523
524 #[cfg(test)]
525 pub(crate) fn enable_write_failure_for_test(&mut self) {
526 self.encryptor.test_enable_write_failure();
527 }
528
529 #[cfg(test)]
530 pub(crate) fn disable_write_failure_for_test(&mut self) {
531 self.encryptor.test_disable_write_failure();
532 }
533
534 #[instrument(skip(self))]
543 pub async fn receive_private_application_messages(
544 &mut self,
545 ) -> Result<Vec<PrivateApplicationMessage>> {
546 private_application_message::receive_private_application_messages(&mut self.encryptor).await
547 }
548}
549
550fn validate_private_application_message_json(raw_json: &str) -> Result<()> {
551 let value: serde_json::Value =
552 serde_json::from_str(raw_json).map_err(|err| PaykitError::Validation(err.to_string()))?;
553 if value
554 .get("version")
555 .and_then(serde_json::Value::as_u64)
556 .is_none_or(|version| u8::try_from(version).is_err())
557 {
558 return Err(PaykitError::Validation(
559 "Private Application Message version must be a u8 integer".into(),
560 ));
561 }
562 if !value.get("kind").is_some_and(serde_json::Value::is_string) {
563 return Err(PaykitError::Validation(
564 "Private Application Message kind must be a string".into(),
565 ));
566 }
567 let app_id = value
568 .get("app_id")
569 .and_then(serde_json::Value::as_str)
570 .ok_or_else(|| {
571 PaykitError::Validation("Private Application Message app_id must be a string".into())
572 })?;
573 PaykitAppId::new(app_id)?;
574 Ok(())
575}
576
577pub const DEFAULT_MAX_SEND_RETRIES: u32 = 3;
582
583#[instrument(skip(link))]
588pub async fn close_encrypted_link(mut link: EncryptedLink) -> Result<()> {
589 debug!("closing Encrypted Link");
590 link.encryptor.close();
591 debug!("Encrypted Link closed successfully");
592 Ok(())
593}
594
595#[instrument(skip(session, secret_key, outbox_client, snapshot))]
607pub async fn restore_encrypted_link(
608 session: pubky::PubkySession,
609 secret_key: [u8; 32],
610 remote_identity_public_key: &PublicKey,
611 outbox_client: pubky::Pubky,
612 snapshot: EncryptedLinkSnapshot,
613) -> Result<EncryptedLink> {
614 debug!("restoring Encrypted Link from snapshot (raw params)");
615
616 let (write_path, read_path) = compute_private_payment_paths(
617 &secret_key,
618 session.info().public_key(),
619 remote_identity_public_key,
620 snapshot.remote_noise_public_key(),
621 snapshot.recovery_context(),
622 );
623
624 let config = pubky_noise::PubkyNoiseConfig::new_with_paths(
625 secret_key,
626 0,
627 "XX",
628 session,
629 write_path,
630 read_path,
631 outbox_client,
632 )
633 .map_err(|err| PaykitError::InvalidData {
634 context: format!("failed to create encryptor config for restore: {err:?}"),
635 source: Some(anyhow::anyhow!(
636 "pubky-noise PubkyNoiseConfig::new failed: {err:?}"
637 )),
638 })?;
639
640 restore_encrypted_link_inner(config, remote_identity_public_key, snapshot).await
641}
642
643#[instrument(skip(config, snapshot))]
657pub async fn restore_encrypted_link_from_config(
658 config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
659 remote_identity_public_key: &PublicKey,
660 snapshot: EncryptedLinkSnapshot,
661) -> Result<EncryptedLink> {
662 debug!("restoring Encrypted Link from snapshot (existing config)");
663 restore_encrypted_link_inner(config, remote_identity_public_key, snapshot).await
664}
665
666async fn restore_encrypted_link_inner(
668 config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
669 remote_identity_public_key: &PublicKey,
670 snapshot: EncryptedLinkSnapshot,
671) -> Result<EncryptedLink> {
672 if snapshot.recipient() != remote_identity_public_key {
673 return Err(PaykitError::Validation(format!(
674 "remote_identity_public_key does not match snapshot recipient (remote={}, snapshot={})",
675 remote_identity_public_key,
676 snapshot.recipient(),
677 )));
678 }
679
680 let phase = snapshot.phase();
681 if !matches!(phase, pubky_noise::snow_crypto::NoisePhase::Transport) {
682 return Err(PaykitError::Validation(format!(
683 "Encrypted Link restore requires transport-phase snapshot, got {:?}",
684 phase,
685 )));
686 }
687
688 let remote_noise_public_key = snapshot.remote_noise_public_key().clone();
689 let recovery_context = snapshot.recovery_context().clone();
690 validate_private_payment_paths(
691 &config,
692 remote_identity_public_key,
693 &remote_noise_public_key,
694 &recovery_context,
695 )?;
696 let state = snapshot.into_state();
697 if state.static_secret != Some(config.pubky_root_keypair.secret_key()) {
698 return Err(PaykitError::Validation(
699 "Noise config key does not match snapshot static key".into(),
700 ));
701 }
702 let encryptor = pubky_noise::PubkyNoiseEncryptor::restore(
703 config.clone(),
704 state,
705 remote_identity_public_key.clone(),
706 )
707 .await
708 .map_err(|err| PaykitError::InvalidData {
709 context: format!("failed to restore Encrypted Link: {err:?}"),
710 source: Some(anyhow::anyhow!("pubky-noise restore failed: {err:?}")),
711 })?;
712
713 debug!("Encrypted Link restored successfully");
714 Ok(EncryptedLink::from_parts(
715 encryptor,
716 remote_identity_public_key.clone(),
717 remote_noise_public_key,
718 recovery_context,
719 config,
720 ))
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726
727 #[test]
728 fn test_validate_private_application_message_json_requires_header() {
729 assert!(validate_private_application_message_json(
730 r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"bitkit"}"#
731 )
732 .is_ok());
733 assert!(validate_private_application_message_json(r#"{"version":1}"#).is_err());
734 assert!(validate_private_application_message_json(r#"{"kind":"paykit.test"}"#).is_err());
735 assert!(
736 validate_private_application_message_json(r#"{"version":1,"kind":"paykit.test"}"#)
737 .is_err()
738 );
739 assert!(validate_private_application_message_json(
740 r#"{"version":1,"kind":"paykit.test","app_id":"bad/path"}"#
741 )
742 .is_err());
743 }
744}