use tracing::{debug, instrument};
use crate::{PaykitAppId, PaykitError, PublicKey, Result};
use super::{
paths::{compute_private_payment_paths, validate_private_payment_paths},
private_application_message::{self, PrivateApplicationMessage},
EncryptedLinkRecoveryContext, EncryptedLinkSnapshot,
};
pub struct PreparedPrivateApplicationMessageSend {
prepared: pubky_noise::PreparedSend,
resulting_snapshot: EncryptedLinkSnapshot,
}
impl PreparedPrivateApplicationMessageSend {
pub fn destination_path(&self) -> &str {
self.prepared.destination_path()
}
pub fn ciphertext(&self) -> &[u8] {
self.prepared.ciphertext()
}
pub fn resulting_snapshot(&self) -> &EncryptedLinkSnapshot {
&self.resulting_snapshot
}
}
impl std::fmt::Debug for PreparedPrivateApplicationMessageSend {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedPrivateApplicationMessageSend")
.field("destination_path", &"<redacted>")
.field(
"ciphertext",
&format_args!("<redacted:{} bytes>", self.ciphertext().len()),
)
.field("resulting_snapshot", &"<redacted>")
.finish()
}
}
pub struct PreparedPrivateApplicationMessageReceive {
prepared: pubky_noise::PreparedReceive,
message: PrivateApplicationMessage,
resulting_snapshot: EncryptedLinkSnapshot,
}
impl PreparedPrivateApplicationMessageReceive {
pub fn message(&self) -> &PrivateApplicationMessage {
&self.message
}
pub fn resulting_snapshot(&self) -> &EncryptedLinkSnapshot {
&self.resulting_snapshot
}
}
impl std::fmt::Debug for PreparedPrivateApplicationMessageReceive {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedPrivateApplicationMessageReceive")
.field("message", &"<redacted>")
.field("resulting_snapshot", &"<redacted>")
.finish()
}
}
pub struct EncryptedLink {
encryptor: pubky_noise::PubkyNoiseEncryptor,
recipient: PublicKey,
remote_noise_public_key: PublicKey,
recovery_context: EncryptedLinkRecoveryContext,
config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
max_send_retries: u32,
}
impl EncryptedLink {
pub fn remote_static_public_key(&self) -> Option<&[u8]> {
self.encryptor.remote_static_public_key()
}
pub(super) fn from_parts(
encryptor: pubky_noise::PubkyNoiseEncryptor,
recipient: PublicKey,
remote_noise_public_key: PublicKey,
recovery_context: EncryptedLinkRecoveryContext,
config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
) -> Self {
Self {
encryptor,
recipient,
remote_noise_public_key,
recovery_context,
config,
max_send_retries: DEFAULT_MAX_SEND_RETRIES,
}
}
pub fn set_max_send_retries(&mut self, max: u32) -> &mut Self {
self.max_send_retries = max;
self
}
pub fn snapshot(&self) -> Result<EncryptedLinkSnapshot> {
Ok(EncryptedLinkSnapshot::from_state(
self.encryptor
.snapshot()
.map_err(|err| PaykitError::InvalidData {
context: format!("capture Encrypted Link snapshot: {err:?}"),
source: None,
})?,
self.recipient.clone(),
self.remote_noise_public_key.clone(),
self.recovery_context.clone(),
))
}
pub fn serialize(&self) -> Result<Vec<u8>> {
Ok(self.snapshot()?.serialize())
}
pub fn config(&self) -> &std::sync::Arc<pubky_noise::PubkyNoiseConfig> {
&self.config
}
pub fn recipient(&self) -> &PublicKey {
&self.recipient
}
async fn send_private_application_message_with_context(
&mut self,
plaintext: &[u8],
context: &'static str,
) -> Result<()> {
private_application_message::send_private_application_message(
&mut self.encryptor,
self.max_send_retries,
plaintext,
context,
)
.await
}
pub(crate) async fn send_private_payment_list_message(
&mut self,
plaintext: &[u8],
) -> Result<()> {
self.send_private_application_message_with_context(plaintext, "Private Payment List")
.await
}
pub(crate) async fn send_receipt_access_message(&mut self, plaintext: &[u8]) -> Result<()> {
self.send_private_application_message_with_context(plaintext, "Receipt Access")
.await
}
pub(crate) async fn send_payment_request_message(&mut self, plaintext: &[u8]) -> Result<()> {
self.send_private_application_message_with_context(plaintext, "Payment Request")
.await
}
pub(crate) async fn send_payment_request_acceptance_message(
&mut self,
plaintext: &[u8],
) -> Result<()> {
self.send_private_application_message_with_context(plaintext, "Payment Request Acceptance")
.await
}
pub(crate) async fn send_payment_request_rejection_message(
&mut self,
plaintext: &[u8],
) -> Result<()> {
self.send_private_application_message_with_context(plaintext, "Payment Request Rejection")
.await
}
pub(crate) async fn send_payment_request_cancellation_message(
&mut self,
plaintext: &[u8],
) -> Result<()> {
self.send_private_application_message_with_context(
plaintext,
"Payment Request Cancellation",
)
.await
}
pub(crate) async fn send_payment_proof_message(&mut self, plaintext: &[u8]) -> Result<()> {
self.send_private_application_message_with_context(plaintext, "Payment Proof")
.await
}
pub(crate) async fn send_payment_conversion_quote_message(
&mut self,
plaintext: &[u8],
) -> Result<()> {
self.send_private_application_message_with_context(plaintext, "Payment Conversion Quote")
.await
}
pub(crate) async fn send_allowance_message(&mut self, plaintext: &[u8]) -> Result<()> {
self.send_private_application_message_with_context(plaintext, "Allowance Event Message")
.await
}
pub async fn send_private_application_message_json(&mut self, raw_json: &str) -> Result<()> {
validate_private_application_message_json(raw_json)?;
self.send_private_application_message_with_context(
raw_json.as_bytes(),
"Private Application Message",
)
.await
}
#[allow(deprecated)]
pub async fn retry_pending_send(&mut self) -> Result<()> {
self.encryptor
.retry_pending_send()
.await
.map_err(|err| PaykitError::Transport {
context: format!("retry pending private send: {err:?}"),
source: anyhow::anyhow!("pubky-noise retry_pending_send failed: {err:?}"),
})
}
pub fn prepare_private_application_message_json(
&mut self,
raw_json: &str,
) -> Result<PreparedPrivateApplicationMessageSend> {
private_application_message::validate_private_application_message_size(
raw_json.as_bytes(),
"Private Application Message",
)?;
validate_private_application_message_json(raw_json)?;
let prepared = self
.encryptor
.prepare_send(raw_json.as_bytes())
.map_err(|err| PaykitError::Transport {
context: format!("prepare Private Application Message send: {err:?}"),
source: anyhow::Error::new(crate::error::NonRetryablePrivateSendError(err)),
})?;
let resulting_snapshot = EncryptedLinkSnapshot::from_state(
prepared.resulting_session_state().clone(),
self.recipient.clone(),
self.remote_noise_public_key.clone(),
self.recovery_context.clone(),
);
Ok(PreparedPrivateApplicationMessageSend {
prepared,
resulting_snapshot,
})
}
pub fn acknowledge_persisted_private_send(
&mut self,
prepared: PreparedPrivateApplicationMessageSend,
) -> Result<()> {
self.encryptor
.acknowledge_persisted_send(prepared.prepared)
.map_err(|err| PaykitError::InvalidData {
context: format!("acknowledge persisted private send: {err:?}"),
source: None,
})
}
pub async fn publish_prepared_private_application_message(
&self,
destination_path: &str,
ciphertext: &[u8],
) -> Result<()> {
let expected_prefix = format!("{}/", self.config.write_path);
if !destination_path.starts_with(&expected_prefix)
|| destination_path[expected_prefix.len()..]
.parse::<u32>()
.is_err()
{
return Err(PaykitError::Validation(
"prepared private send destination does not belong to this Encrypted Link".into(),
));
}
if ciphertext.len() != pubky_noise::snow_crypto::PUBKY_NOISE_TRANSPORT_PACKET_LEN {
return Err(PaykitError::InvalidData {
context: "prepared private send ciphertext has an invalid length".into(),
source: None,
});
}
let max_attempts = self.max_send_retries.saturating_add(1);
let mut last_error = None;
for attempt in 1..=max_attempts {
match self
.config
.local_session
.storage()
.put(destination_path, ciphertext.to_vec())
.await
{
Ok(_) => return Ok(()),
Err(err) => {
last_error = Some(err);
if attempt < max_attempts {
tracing::warn!(
attempt,
max_retries = self.max_send_retries,
"prepared private message publication failed, retrying"
);
}
}
}
}
Err(PaykitError::Transport {
context: format!(
"failed to publish prepared Private Application Message after {max_attempts} attempts"
),
source: last_error
.expect("at least one prepared private send attempt must run")
.into(),
})
}
pub async fn prepare_next_private_application_message(
&mut self,
) -> Result<Option<PreparedPrivateApplicationMessageReceive>> {
let path = self
.encryptor
.next_receive_path()
.map_err(|err| PaykitError::Transport {
context: format!("prepare Private Application Message receive path: {err:?}"),
source: anyhow::Error::new(crate::error::NonRetryablePrivateReceiveError(err)),
})?;
let response = match self.config.outbox_client.public_storage().get(path).await {
Ok(response) => response,
Err(pubky::Error::Request(pubky::errors::RequestError::Server { status, .. }))
if status == pubky::StatusCode::NOT_FOUND || status == pubky::StatusCode::GONE =>
{
return Ok(None)
}
Err(err) => {
return Err(PaykitError::Transport {
context: "fetch next Private Application Message ciphertext".into(),
source: err.into(),
})
}
};
let ciphertext = crate::pubky_routing::read_bounded_body(
response,
pubky_noise::snow_crypto::PUBKY_NOISE_TRANSPORT_PACKET_LEN,
"read next Private Application Message ciphertext",
)
.await?;
let prepared =
self.encryptor
.prepare_receive(&ciphertext)
.map_err(|err| PaykitError::Transport {
context: format!("prepare Private Application Message receive: {err:?}"),
source: anyhow::Error::new(crate::error::NonRetryablePrivateReceiveError(err)),
})?;
let message =
private_application_message::decode_private_application_message(prepared.plaintext())?;
let resulting_snapshot = EncryptedLinkSnapshot::from_state(
prepared.resulting_session_state().clone(),
self.recipient.clone(),
self.remote_noise_public_key.clone(),
self.recovery_context.clone(),
);
Ok(Some(PreparedPrivateApplicationMessageReceive {
prepared,
message,
resulting_snapshot,
}))
}
pub fn acknowledge_persisted_private_receive(
&mut self,
prepared: PreparedPrivateApplicationMessageReceive,
) -> Result<()> {
self.encryptor
.acknowledge_persisted_receive(prepared.prepared)
.map_err(|err| PaykitError::InvalidData {
context: format!("acknowledge persisted private receive: {err:?}"),
source: None,
})
}
#[cfg(test)]
pub(crate) async fn send_private_application_message_for_test(
&mut self,
plaintext: &[u8],
) -> Result<()> {
self.send_private_application_message_with_context(
plaintext,
"raw test Private Application Message",
)
.await
}
#[cfg(test)]
pub(crate) fn next_receive_path_for_test(&self) -> Result<String> {
self.encryptor
.next_receive_path()
.map_err(|err| PaykitError::Transport {
context: format!("next_receive_path: {err:?}"),
source: anyhow::anyhow!("pubky-noise next_receive_path failed: {err:?}"),
})
}
#[cfg(test)]
pub(crate) fn enable_write_failure_for_test(&mut self) {
self.encryptor.test_enable_write_failure();
}
#[cfg(test)]
pub(crate) fn disable_write_failure_for_test(&mut self) {
self.encryptor.test_disable_write_failure();
}
#[instrument(skip(self))]
pub async fn receive_private_application_messages(
&mut self,
) -> Result<Vec<PrivateApplicationMessage>> {
private_application_message::receive_private_application_messages(&mut self.encryptor).await
}
}
fn validate_private_application_message_json(raw_json: &str) -> Result<()> {
let value: serde_json::Value =
serde_json::from_str(raw_json).map_err(|err| PaykitError::Validation(err.to_string()))?;
if value
.get("version")
.and_then(serde_json::Value::as_u64)
.is_none_or(|version| u8::try_from(version).is_err())
{
return Err(PaykitError::Validation(
"Private Application Message version must be a u8 integer".into(),
));
}
if !value.get("kind").is_some_and(serde_json::Value::is_string) {
return Err(PaykitError::Validation(
"Private Application Message kind must be a string".into(),
));
}
let app_id = value
.get("app_id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
PaykitError::Validation("Private Application Message app_id must be a string".into())
})?;
PaykitAppId::new(app_id)?;
Ok(())
}
pub const DEFAULT_MAX_SEND_RETRIES: u32 = 3;
#[instrument(skip(link))]
pub async fn close_encrypted_link(mut link: EncryptedLink) -> Result<()> {
debug!("closing Encrypted Link");
link.encryptor.close();
debug!("Encrypted Link closed successfully");
Ok(())
}
#[instrument(skip(session, secret_key, outbox_client, snapshot))]
pub async fn restore_encrypted_link(
session: pubky::PubkySession,
secret_key: [u8; 32],
remote_identity_public_key: &PublicKey,
outbox_client: pubky::Pubky,
snapshot: EncryptedLinkSnapshot,
) -> Result<EncryptedLink> {
debug!("restoring Encrypted Link from snapshot (raw params)");
let (write_path, read_path) = compute_private_payment_paths(
&secret_key,
session.info().public_key(),
remote_identity_public_key,
snapshot.remote_noise_public_key(),
snapshot.recovery_context(),
);
let config = pubky_noise::PubkyNoiseConfig::new_with_paths(
secret_key,
0,
"XX",
session,
write_path,
read_path,
outbox_client,
)
.map_err(|err| PaykitError::InvalidData {
context: format!("failed to create encryptor config for restore: {err:?}"),
source: Some(anyhow::anyhow!(
"pubky-noise PubkyNoiseConfig::new failed: {err:?}"
)),
})?;
restore_encrypted_link_inner(config, remote_identity_public_key, snapshot).await
}
#[instrument(skip(config, snapshot))]
pub async fn restore_encrypted_link_from_config(
config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
remote_identity_public_key: &PublicKey,
snapshot: EncryptedLinkSnapshot,
) -> Result<EncryptedLink> {
debug!("restoring Encrypted Link from snapshot (existing config)");
restore_encrypted_link_inner(config, remote_identity_public_key, snapshot).await
}
async fn restore_encrypted_link_inner(
config: std::sync::Arc<pubky_noise::PubkyNoiseConfig>,
remote_identity_public_key: &PublicKey,
snapshot: EncryptedLinkSnapshot,
) -> Result<EncryptedLink> {
if snapshot.recipient() != remote_identity_public_key {
return Err(PaykitError::Validation(format!(
"remote_identity_public_key does not match snapshot recipient (remote={}, snapshot={})",
remote_identity_public_key,
snapshot.recipient(),
)));
}
let phase = snapshot.phase();
if !matches!(phase, pubky_noise::snow_crypto::NoisePhase::Transport) {
return Err(PaykitError::Validation(format!(
"Encrypted Link restore requires transport-phase snapshot, got {:?}",
phase,
)));
}
let remote_noise_public_key = snapshot.remote_noise_public_key().clone();
let recovery_context = snapshot.recovery_context().clone();
validate_private_payment_paths(
&config,
remote_identity_public_key,
&remote_noise_public_key,
&recovery_context,
)?;
let state = snapshot.into_state();
if state.static_secret != Some(config.pubky_root_keypair.secret_key()) {
return Err(PaykitError::Validation(
"Noise config key does not match snapshot static key".into(),
));
}
let encryptor = pubky_noise::PubkyNoiseEncryptor::restore(
config.clone(),
state,
remote_identity_public_key.clone(),
)
.await
.map_err(|err| PaykitError::InvalidData {
context: format!("failed to restore Encrypted Link: {err:?}"),
source: Some(anyhow::anyhow!("pubky-noise restore failed: {err:?}")),
})?;
debug!("Encrypted Link restored successfully");
Ok(EncryptedLink::from_parts(
encryptor,
remote_identity_public_key.clone(),
remote_noise_public_key,
recovery_context,
config,
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_private_application_message_json_requires_header() {
assert!(validate_private_application_message_json(
r#"{"version":1,"kind":"paykit.private_payment_list","app_id":"bitkit"}"#
)
.is_ok());
assert!(validate_private_application_message_json(r#"{"version":1}"#).is_err());
assert!(validate_private_application_message_json(r#"{"kind":"paykit.test"}"#).is_err());
assert!(
validate_private_application_message_json(r#"{"version":1,"kind":"paykit.test"}"#)
.is_err()
);
assert!(validate_private_application_message_json(
r#"{"version":1,"kind":"paykit.test","app_id":"bad/path"}"#
)
.is_err());
}
}