Skip to main content

paykit_lib/encrypted_link/
link.rs

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
11/// Outbound private message prepared for atomic persistence before publication.
12///
13/// Persist [`destination_path`](Self::destination_path),
14/// [`ciphertext`](Self::ciphertext), and
15/// [`resulting_snapshot`](Self::resulting_snapshot) together before
16/// acknowledging this value.
17pub struct PreparedPrivateApplicationMessageSend {
18    prepared: pubky_noise::PreparedSend,
19    resulting_snapshot: EncryptedLinkSnapshot,
20}
21
22impl PreparedPrivateApplicationMessageSend {
23    /// Pubky storage path for the exact ciphertext.
24    pub fn destination_path(&self) -> &str {
25        self.prepared.destination_path()
26    }
27
28    /// Exact ciphertext to publish or retry after a crash.
29    pub fn ciphertext(&self) -> &[u8] {
30        self.prepared.ciphertext()
31    }
32
33    /// Encrypted Link snapshot after this send.
34    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
52/// Inbound private message prepared for atomic application-state persistence.
53pub struct PreparedPrivateApplicationMessageReceive {
54    prepared: pubky_noise::PreparedReceive,
55    message: PrivateApplicationMessage,
56    resulting_snapshot: EncryptedLinkSnapshot,
57}
58
59impl PreparedPrivateApplicationMessageReceive {
60    /// Decrypted Paykit application message.
61    pub fn message(&self) -> &PrivateApplicationMessage {
62        &self.message
63    }
64
65    /// Encrypted Link snapshot after this receive.
66    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
80/// Handle to an established Encrypted Link with a counterparty.
81///
82/// Created by [`advance_handshake`](crate::advance_handshake) (via
83/// [`HandshakeProgress::Complete`](crate::HandshakeProgress::Complete)) after
84/// a successful Noise handshake. It wraps a transport-mode
85/// [`pubky_noise::PubkyNoiseEncryptor`] and should be closed with
86/// [`close_encrypted_link`] when no longer needed.
87///
88/// # Session resumption
89///
90/// An established Encrypted Link can be snapshotted via [`snapshot`](Self::snapshot)
91/// or serialized via [`serialize`](Self::serialize), then restored with
92/// [`restore_encrypted_link`] or [`restore_encrypted_link_from_config`].
93///
94/// # Private Application Message stream
95///
96/// All private Paykit protocol messages on this link share one ordered stream.
97/// Use [`receive_private_application_messages`](Self::receive_private_application_messages)
98/// for ordered intake. Callers or SDK layers that derive durable Event Message
99/// state should persist and route the returned messages before replacing a
100/// stored link snapshot whose read counter has advanced.
101///
102/// # Automatic send retry
103///
104/// Send helpers retry transient homeserver write failures according to
105/// [`set_max_send_retries`](Self::set_max_send_retries). Deterministic Noise
106/// state, counter, nonce, or encryption errors fail immediately.
107pub struct EncryptedLink {
108    /// The Noise session manager in transport mode.
109    encryptor: pubky_noise::PubkyNoiseEncryptor,
110    /// The counterparty's Pubky identity key.
111    recipient: PublicKey,
112    /// The counterparty's identity-wide Noise public key.
113    remote_noise_public_key: PublicKey,
114    recovery_context: EncryptedLinkRecoveryContext,
115    /// Shared Noise configuration retained for snapshot-based session resumption.
116    config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
117    /// Maximum number of automatic Private Application Message `send_message`
118    /// retries.
119    max_send_retries: u32,
120}
121
122impl EncryptedLink {
123    /// X25519 static key authenticated by the Noise transcript.
124    /// The caller must bind this key to the expected identity, including after restore.
125    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    /// Set the maximum number of automatic Private Application Message
147    /// `send_message` retries before Paykit gives up and returns
148    /// [`PaykitError::Transport`].
149    ///
150    /// Retryable homeserver write failures are retried. Deterministic Noise
151    /// state, counter, nonce, or encryption errors fail immediately.
152    ///
153    /// Default: [`DEFAULT_MAX_SEND_RETRIES`] (3).
154    pub fn set_max_send_retries(&mut self, max: u32) -> &mut Self {
155        self.max_send_retries = max;
156        self
157    }
158
159    /// Capture the current link state as a serializable snapshot.
160    ///
161    /// The snapshot captures transport keys, counters, and counterparty identity.
162    /// Take a new snapshot after receiving or sending messages when the caller
163    /// wants the persisted read/write counters to catch up with local state.
164    ///
165    /// Snapshot bytes include sensitive key material and must be stored as
166    /// secrets. Do not log them or include them in telemetry.
167    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    /// Serialize the current link state to bytes for persistence.
182    ///
183    /// Convenience method equivalent to `self.snapshot()?.serialize()`.
184    pub fn serialize(&self) -> Result<Vec<u8>> {
185        Ok(self.snapshot()?.serialize())
186    }
187
188    /// Access the shared Noise configuration for this link.
189    ///
190    /// Useful for passing to [`restore_encrypted_link_from_config`] when
191    /// performing in-process session recovery without an app restart.
192    pub fn config(&self) -> &std::sync::Arc<pubky_noise::PubkyNoiseConfig> {
193        &self.config
194    }
195
196    /// Access the counterparty public key for this Encrypted Link.
197    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    /// Send one raw JSON Private Application Message.
279    ///
280    /// This is the low-level send counterpart to
281    /// [`receive_private_application_messages`](Self::receive_private_application_messages).
282    /// It validates the generic `version`, `kind`, and `app_id` envelope
283    /// fields; it does not require a known Paykit kind or validate known Paykit
284    /// message bodies. Use the typed serializers or SDK queue for
285    /// protocol-managed Paykit messages.
286    ///
287    /// Higher-level callers should persist the exact JSON before sending when
288    /// retrying the same message matters.
289    ///
290    /// Transient outbox write failures are retried up to the configured budget.
291    /// If every attempt fails, the prepared packet is retained and blocks
292    /// further sends, receives, and snapshots on this link until it is
293    /// republished with [`retry_pending_send`](Self::retry_pending_send).
294    ///
295    /// Do not restore an older snapshot to recover from a failed send: the
296    /// failed write may still have reached the homeserver, and rolling back
297    /// the sending nonce and slot would reuse the transport key and nonce for
298    /// the next plaintext. Only the identical prepared packet may be
299    /// republished; otherwise establish a fresh link.
300    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    /// Republish the prepared packet retained after a failed convenience send.
310    ///
311    /// Until the identical packet is republished, sends, receives, and snapshots
312    /// remain blocked. Never restore an older snapshot to retry with different
313    /// plaintext: an ambiguous write may already have used that key and nonce.
314    /// SDK callers using staged sends should instead publish their durably
315    /// persisted ciphertext with [`publish_prepared_private_application_message`](Self::publish_prepared_private_application_message).
316    #[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    /// Prepare one raw JSON Private Application Message for durable handoff.
328    ///
329    /// Persist the returned ciphertext and resulting snapshot atomically before
330    /// calling [`acknowledge_persisted_private_send`](Self::acknowledge_persisted_private_send).
331    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    /// Acknowledge that a prepared send and its resulting snapshot are durable.
360    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    /// Publish an exact prepared ciphertext, retrying without re-encryption.
373    ///
374    /// This accepts values restored from durable SDK state after a crash. The
375    /// destination must belong to this link's local write stream.
376    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    /// Fetch and prepare the next available inbound Private Application Message.
433    ///
434    /// Persist the application result and returned snapshot atomically before
435    /// calling [`acknowledge_persisted_private_receive`](Self::acknowledge_persisted_private_receive).
436    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        // Bound hostile outbox bodies before buffering or authenticating them.
461        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    /// Acknowledge that a prepared receive and its application result are durable.
490    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    /// Receive a bounded batch of available Private Application Messages in
535    /// stream order.
536    ///
537    /// The Noise read checkpoint advances past the returned messages. Callers
538    /// that need crash-safe Event Message handling should persist returned
539    /// messages before replacing a stored link snapshot. Call again to drain
540    /// more than [`PRIVATE_APPLICATION_MESSAGE_RECEIVE_LIMIT`](crate::PRIVATE_APPLICATION_MESSAGE_RECEIVE_LIMIT)
541    /// available stream slots.
542    #[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
577/// Default maximum number of automatic Private Application Message
578/// `send_message` retries before Paykit gives up and returns an error.
579///
580/// Override per-link via [`EncryptedLink::set_max_send_retries`].
581pub const DEFAULT_MAX_SEND_RETRIES: u32 = 3;
582
583/// Closes an Encrypted Link and cleans up the Noise session state.
584///
585/// After calling this function, the [`EncryptedLink`] is consumed and can no
586/// longer be used for encryption or decryption.
587#[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/// Restores an [`EncryptedLink`] from a previously saved snapshot.
596///
597/// Use this after an app restart when the original Noise configuration is no
598/// longer available. The caller supplies a fresh authenticated Pubky session,
599/// the same local secret key used for the original link, the counterparty
600/// public key, and the saved snapshot.
601///
602/// Restored links reset `max_send_retries` to [`DEFAULT_MAX_SEND_RETRIES`].
603/// `remote_identity_public_key` must match `snapshot.recipient()`.
604/// Download failures return [`PaykitError::Transport`]; retain the snapshot to retry.
605/// Missing or invalid handshake transcripts return [`PaykitError::InvalidData`].
606#[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/// Restores an [`EncryptedLink`] from a previously saved snapshot using an
644/// existing Noise configuration.
645///
646/// Use this in-process when the original `Arc<PubkyNoiseConfig>` is still
647/// available. For cross-restart recovery, use [`restore_encrypted_link`].
648///
649/// Restored links reset `max_send_retries` to [`DEFAULT_MAX_SEND_RETRIES`].
650/// `remote_identity_public_key` must match `snapshot.recipient()`.
651/// The config's Noise key must match the snapshot's static key.
652/// The config paths must match the session's local Pubky identity, the remote
653/// identity, and the Noise keys; mismatches return [`PaykitError::Validation`].
654/// Download failures return [`PaykitError::Transport`]; missing or invalid
655/// handshake transcripts return [`PaykitError::InvalidData`].
656#[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
666/// Shared implementation for both restore variants.
667async 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}