paykit_lib/encrypted_link/
private_application_message.rs1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
2use pubky::{errors::RequestError, Error as PubkyError, PubkySession, PublicKey, StatusCode};
3use tracing::{debug, warn};
4
5use crate::{
6 encrypted_link::paths::compute_private_payment_paths,
7 error::{NonRetryablePrivateReceiveError, NonRetryablePrivateSendError},
8 PaykitError, Result,
9};
10
11const INVALID_UTF8_PRIVATE_MESSAGE_PREFIX: &str = "paykit.invalid_utf8_private_message:";
16const LIST_PAGE_LIMIT: u16 = 100;
17pub const PRIVATE_APPLICATION_MESSAGE_RECEIVE_LIMIT: usize = 100;
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub enum PrivateMessageKind {
28 PrivatePaymentList,
30 DeliveryConfirmation,
32 ReceiptAccess,
34 PaymentRequest,
36 PaymentRequestAcceptance,
38 PaymentRequestRejection,
40 PaymentRequestCancellation,
42 PaymentConversionQuote,
44 PaymentProof,
46 AllowanceProposal,
48 AllowanceAcceptance,
50 AllowanceRejection,
52 AllowanceEnd,
54}
55
56impl PrivateMessageKind {
57 pub fn as_str(self) -> &'static str {
59 match self {
60 Self::PrivatePaymentList => "paykit.private_payment_list",
61 Self::DeliveryConfirmation => "paykit.delivery_confirmation",
62 Self::ReceiptAccess => "paykit.receipt_access",
63 Self::PaymentRequest => "paykit.payment_request",
64 Self::PaymentRequestAcceptance => "paykit.payment_request_acceptance",
65 Self::PaymentRequestRejection => "paykit.payment_request_rejection",
66 Self::PaymentRequestCancellation => "paykit.payment_request_cancellation",
67 Self::PaymentProof => "paykit.payment_proof",
68 Self::PaymentConversionQuote => "paykit.payment_conversion_quote",
69 Self::AllowanceProposal => "paykit.allowance_proposal",
70 Self::AllowanceAcceptance => "paykit.allowance_acceptance",
71 Self::AllowanceRejection => "paykit.allowance_rejection",
72 Self::AllowanceEnd => "paykit.allowance_end",
73 }
74 }
75
76 pub fn parse(kind: &str) -> Option<Self> {
78 match kind {
79 "paykit.private_payment_list" => Some(Self::PrivatePaymentList),
80 "paykit.delivery_confirmation" => Some(Self::DeliveryConfirmation),
81 "paykit.receipt_access" => Some(Self::ReceiptAccess),
82 "paykit.payment_request" => Some(Self::PaymentRequest),
83 "paykit.payment_request_acceptance" => Some(Self::PaymentRequestAcceptance),
84 "paykit.payment_request_rejection" => Some(Self::PaymentRequestRejection),
85 "paykit.payment_request_cancellation" => Some(Self::PaymentRequestCancellation),
86 "paykit.payment_proof" => Some(Self::PaymentProof),
87 "paykit.payment_conversion_quote" => Some(Self::PaymentConversionQuote),
88 "paykit.allowance_proposal" => Some(Self::AllowanceProposal),
89 "paykit.allowance_acceptance" => Some(Self::AllowanceAcceptance),
90 "paykit.allowance_rejection" => Some(Self::AllowanceRejection),
91 "paykit.allowance_end" => Some(Self::AllowanceEnd),
92 _ => None,
93 }
94 }
95
96 pub fn is_event(self) -> bool {
101 match self {
102 Self::ReceiptAccess
103 | Self::PaymentRequest
104 | Self::PaymentRequestAcceptance
105 | Self::PaymentRequestRejection
106 | Self::PaymentRequestCancellation
107 | Self::PaymentConversionQuote
108 | Self::AllowanceProposal
109 | Self::AllowanceAcceptance
110 | Self::AllowanceRejection
111 | Self::AllowanceEnd
112 | Self::PaymentProof => true,
113 Self::PrivatePaymentList | Self::DeliveryConfirmation => false,
114 }
115 }
116}
117
118impl std::fmt::Display for PrivateMessageKind {
119 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
120 f.write_str(self.as_str())
121 }
122}
123
124#[derive(Clone, PartialEq, Eq)]
132pub struct PrivateApplicationMessage {
133 pub version: Option<u8>,
136 pub kind: Option<String>,
139 pub app_id: Option<String>,
141 pub raw_json: String,
143}
144
145impl std::fmt::Debug for PrivateApplicationMessage {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 f.debug_struct("PrivateApplicationMessage")
148 .field("version", &self.version)
149 .field("kind", &self.kind)
150 .field("app_id", &self.app_id)
151 .field(
152 "raw_json",
153 &format_args!("<redacted:{} bytes>", self.raw_json.len()),
154 )
155 .finish()
156 }
157}
158
159impl PrivateApplicationMessage {
160 pub(super) fn from_plaintext(plaintext: String) -> Self {
161 let value = serde_json::from_str::<serde_json::Value>(&plaintext).ok();
162 let version = value
163 .as_ref()
164 .and_then(|value| value.get("version"))
165 .and_then(serde_json::Value::as_u64)
166 .and_then(|version| u8::try_from(version).ok());
167 let kind = value
168 .as_ref()
169 .and_then(|value| value.get("kind"))
170 .and_then(serde_json::Value::as_str)
171 .map(str::to_owned);
172 let app_id = value
173 .as_ref()
174 .and_then(|value| value.get("app_id"))
175 .and_then(serde_json::Value::as_str)
176 .map(str::to_owned);
177 Self {
178 version,
179 kind,
180 app_id,
181 raw_json: plaintext,
182 }
183 }
184
185 pub fn known_kind(&self) -> Option<PrivateMessageKind> {
188 serde_json::from_str::<serde_json::Value>(&self.raw_json)
189 .ok()
190 .and_then(|value| {
191 value
192 .get("kind")
193 .and_then(serde_json::Value::as_str)
194 .and_then(PrivateMessageKind::parse)
195 })
196 }
197
198 pub fn invalid_utf8_error(&self) -> Option<&'static str> {
204 self.raw_json
205 .starts_with(INVALID_UTF8_PRIVATE_MESSAGE_PREFIX)
206 .then_some("Private Application Message plaintext is not valid UTF-8")
207 }
208}
209
210pub(super) fn decode_private_application_message(raw: &[u8]) -> Result<PrivateApplicationMessage> {
211 let plaintext = match std::str::from_utf8(raw) {
214 Ok(plaintext) => plaintext.to_owned(),
215 Err(_) => format!(
216 "{INVALID_UTF8_PRIVATE_MESSAGE_PREFIX}{}",
217 URL_SAFE_NO_PAD.encode(raw)
218 ),
219 };
220
221 Ok(PrivateApplicationMessage::from_plaintext(plaintext))
222}
223
224pub(super) async fn receive_private_application_messages(
225 encryptor: &mut pubky_noise::PubkyNoiseEncryptor,
226) -> Result<Vec<PrivateApplicationMessage>> {
227 let mut messages = Vec::new();
228
229 while messages.len() < PRIVATE_APPLICATION_MESSAGE_RECEIVE_LIMIT {
230 let batch = encryptor.receive_message().await.map_err(|err| {
231 let context = format!("failed to receive Private Application Messages: {err:?}");
232 let source = if is_retryable_private_receive_error(&err) {
233 anyhow::anyhow!("pubky-noise receive_message failed: {err:?}")
234 } else {
235 anyhow::Error::new(NonRetryablePrivateReceiveError(err))
236 };
237 PaykitError::Transport { context, source }
238 })?;
239
240 if batch.is_empty() {
241 break;
242 }
243
244 for raw in batch {
245 messages.push(decode_private_application_message(&raw)?);
246 }
247 }
248
249 Ok(messages)
250}
251
252pub async fn clear_encrypted_link_outbox(
262 session: &PubkySession,
263 local_secret_key: &[u8; 32],
264 remote_identity_public_key: &PublicKey,
265 remote_noise_public_key: &PublicKey,
266 recovery_context: &super::EncryptedLinkRecoveryContext,
267) -> Result<usize> {
268 let (write_path, _) = compute_private_payment_paths(
269 local_secret_key,
270 session.info().public_key(),
271 remote_identity_public_key,
272 remote_noise_public_key,
273 recovery_context,
274 );
275 let list_path = format!("{write_path}/");
276 let storage = session.storage();
277 let mut deleted_count = 0;
278
279 loop {
280 let builder = match storage.list(&list_path) {
281 Ok(builder) => builder.shallow(true).limit(LIST_PAGE_LIMIT),
282 Err(err) if is_not_found(&err) => return Ok(deleted_count),
283 Err(err) => {
284 return Err(PaykitError::Transport {
285 context: "list Encrypted Link outbox".into(),
286 source: err.into(),
287 });
288 }
289 };
290
291 let page = match builder.send().await {
292 Ok(page) => page,
293 Err(err) if is_not_found(&err) => return Ok(deleted_count),
294 Err(err) => {
295 return Err(PaykitError::Transport {
296 context: "list Encrypted Link outbox".into(),
297 source: err.into(),
298 });
299 }
300 };
301
302 if page.is_empty() {
303 return Ok(deleted_count);
304 }
305
306 let page_len = page.len();
307 for resource in page {
308 match storage.delete(resource.path.as_str()).await {
309 Ok(_) => deleted_count += 1,
310 Err(err) if is_not_found(&err) => {}
311 Err(err) => {
312 return Err(PaykitError::Transport {
313 context: "clear Encrypted Link outbox".into(),
314 source: err.into(),
315 });
316 }
317 }
318 }
319
320 if page_len < LIST_PAGE_LIMIT as usize {
321 return Ok(deleted_count);
322 }
323 }
324}
325
326fn send_attempts_from_retries(max_send_retries: u32) -> u32 {
327 max_send_retries.saturating_add(1)
328}
329
330fn is_retryable_private_send_error(err: &pubky_noise::PubkyNoiseError) -> bool {
331 matches!(err, pubky_noise::PubkyNoiseError::HomeserverWriteError)
332}
333
334fn is_retryable_private_receive_error(err: &pubky_noise::PubkyNoiseError) -> bool {
335 matches!(
338 err,
339 pubky_noise::PubkyNoiseError::HomeserverResponseError
340 | pubky_noise::PubkyNoiseError::HomeserverHttpError { .. }
341 )
342}
343
344fn is_not_found(err: &PubkyError) -> bool {
345 matches!(
346 err,
347 PubkyError::Request(RequestError::Server { status, .. })
348 if *status == StatusCode::NOT_FOUND || *status == StatusCode::GONE
349 )
350}
351
352pub(super) fn validate_private_application_message_size(
353 plaintext: &[u8],
354 context: &'static str,
355) -> Result<()> {
356 if plaintext.len() > pubky_noise::snow_crypto::PUBKY_NOISE_MSG_LEN {
357 return Err(PaykitError::Validation(format!(
358 "{context} payload ({} bytes) exceeds max message size ({} bytes)",
359 plaintext.len(),
360 pubky_noise::snow_crypto::PUBKY_NOISE_MSG_LEN,
361 )));
362 }
363 Ok(())
364}
365
366#[allow(
367 deprecated,
368 reason = "the stateless convenience API cannot persist a prepared send"
369)]
370pub(super) async fn send_private_application_message(
371 encryptor: &mut pubky_noise::PubkyNoiseEncryptor,
372 max_send_retries: u32,
373 plaintext: &[u8],
374 context: &'static str,
375) -> Result<()> {
376 validate_private_application_message_size(plaintext, context)?;
377
378 let max_attempts = send_attempts_from_retries(max_send_retries);
379 let mut last_error: Option<String> = None;
380 let mut retry_pending = false;
385
386 for attempt in 1..=max_attempts {
387 let outcome = if retry_pending {
388 encryptor.retry_pending_send().await
389 } else {
390 encryptor.send_message(plaintext).await
391 };
392 match outcome {
393 Ok(()) => {
394 debug!(context, "Private Application Message sent successfully");
395 return Ok(());
396 }
397 Err(err) if is_retryable_private_send_error(&err) => {
398 retry_pending = true;
399 last_error = Some(format!("{err:?}"));
400 if attempt < max_attempts {
401 warn!(
402 attempt,
403 max_retries = max_send_retries,
404 error = ?err,
405 context,
406 "send_message failed, retrying"
407 );
408 }
409 }
410 Err(err) => {
411 let context = format!("failed to send {context}: {err:?}");
412 return Err(PaykitError::Transport {
413 context,
414 source: anyhow::Error::new(NonRetryablePrivateSendError(err)),
415 });
416 }
417 }
418 }
419
420 Err(PaykitError::Transport {
421 context: format!("failed to send {context} after {max_attempts} attempts"),
422 source: anyhow::anyhow!(
423 "pubky-noise send_message failed on all {} attempts; last error: {}",
424 max_attempts,
425 last_error.unwrap_or_else(|| "unknown error".to_string())
426 ),
427 })
428}
429
430#[cfg(test)]
431mod tests;