Skip to main content

paykit_lib/receipt/
types.rs

1use std::fmt;
2
3use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
4use chacha20poly1305::{aead::OsRng, KeyInit, XChaCha20Poly1305};
5use serde_json::{Map as JsonMap, Value as JsonValue};
6use zeroize::{Zeroize, Zeroizing};
7
8use crate::{
9    validation::validate_uuid_v4, BillingPeriod, EventId, PaykitError, PaymentAmount,
10    PaymentEndpointIdentifier, PaymentReference, PaymentRequestId, PrivateMessageKind, PublicKey,
11    Result,
12};
13
14pub(crate) const RECEIPT_ENCRYPTION_ALGORITHM: &str = "XChaCha20Poly1305";
15/// Maximum encoded size of one stored Encrypted Receipt.
16pub const ENCRYPTED_RECEIPT_MAX_BYTES: usize = 256 * 1024;
17
18/// UUID-v4 identifier for one stored Receipt artifact.
19#[derive(Clone, Debug, PartialEq, Eq, Hash)]
20pub struct ReceiptId(String);
21
22impl ReceiptId {
23    /// Create a Receipt ID from a UUID-v4 string.
24    pub fn new(id: impl Into<String>) -> Result<Self> {
25        validate_uuid_v4(id.into(), "Receipt ID").map(Self)
26    }
27
28    /// Generate a fresh Receipt ID.
29    pub fn new_v4() -> Self {
30        Self(uuid::Uuid::new_v4().to_string())
31    }
32
33    /// Access the canonical UUID string.
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39impl std::fmt::Display for ReceiptId {
40    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41        f.write_str(&self.0)
42    }
43}
44
45impl AsRef<str> for ReceiptId {
46    fn as_ref(&self) -> &str {
47        &self.0
48    }
49}
50
51/// Caller-provided receipt fields. [`prepare_receipt`](crate::prepare_receipt)
52/// fills in the recipient public key from the established Encrypted Link before
53/// encrypting storage.
54#[derive(Clone, PartialEq, Eq)]
55pub struct ReceiptDraft {
56    /// Optional Receipt ID. [`prepare_receipt`](crate::prepare_receipt)
57    /// generates one when this is `None`.
58    pub receipt_id: Option<ReceiptId>,
59    /// Payment Reference being receipted.
60    pub payment_reference: PaymentReference,
61    /// Optional Payment Request ID this receipt corresponds to.
62    pub payment_request_id: Option<PaymentRequestId>,
63    /// Optional Billing Period for recurring Payment Request receipts.
64    pub billing_period: Option<BillingPeriod>,
65    /// Optional Payment Endpoint Identifier used for the payment.
66    pub payment_endpoint_identifier: Option<PaymentEndpointIdentifier>,
67    /// Optional Payment Amount being receipted.
68    pub amount: Option<PaymentAmount>,
69    /// Caller-defined Receipt Metadata as a JSON object.
70    pub metadata: JsonMap<String, JsonValue>,
71}
72
73impl fmt::Debug for ReceiptDraft {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.debug_struct("ReceiptDraft")
76            .field("receipt_id", &self.receipt_id)
77            .field("payment_reference", &"<redacted>")
78            .field("payment_request_id", &self.payment_request_id)
79            .field("billing_period", &self.billing_period)
80            .field(
81                "payment_endpoint_identifier",
82                &self.payment_endpoint_identifier,
83            )
84            .field("amount", &self.amount.as_ref().map(|_| "<redacted>"))
85            .field(
86                "metadata",
87                &format_args!("<redacted:{} fields>", self.metadata.len()),
88            )
89            .finish()
90    }
91}
92
93/// Canonical receipt plaintext encrypted before storage.
94#[derive(Clone, PartialEq, Eq)]
95pub struct Receipt {
96    /// Receipt artifact identifier.
97    pub receipt_id: ReceiptId,
98    /// Payment Reference this receipt corresponds to.
99    pub payment_reference: PaymentReference,
100    /// Optional Payment Request ID this receipt corresponds to.
101    pub payment_request_id: Option<PaymentRequestId>,
102    /// Optional Billing Period for recurring Payment Request receipts.
103    pub billing_period: Option<BillingPeriod>,
104    /// Public key of the intended receipt recipient.
105    pub recipient_public_key: PublicKey,
106    /// Optional Payment Endpoint Identifier used for the payment.
107    pub payment_endpoint_identifier: Option<PaymentEndpointIdentifier>,
108    /// Optional Payment Amount this receipt corresponds to.
109    pub amount: Option<PaymentAmount>,
110    /// Caller-defined Receipt Metadata as a JSON object.
111    pub metadata: JsonMap<String, JsonValue>,
112}
113
114impl fmt::Debug for Receipt {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.debug_struct("Receipt")
117            .field("receipt_id", &self.receipt_id)
118            .field("payment_reference", &"<redacted>")
119            .field("payment_request_id", &self.payment_request_id)
120            .field("billing_period", &self.billing_period)
121            .field("recipient_public_key", &self.recipient_public_key)
122            .field(
123                "payment_endpoint_identifier",
124                &self.payment_endpoint_identifier,
125            )
126            .field("amount", &self.amount.as_ref().map(|_| "<redacted>"))
127            .field(
128                "metadata",
129                &format_args!("<redacted:{} fields>", self.metadata.len()),
130            )
131            .finish()
132    }
133}
134
135/// Symmetric key used to decrypt an Encrypted Receipt.
136///
137/// The key material is intentionally redacted from [`Debug`](std::fmt::Debug)
138/// and [`Display`](std::fmt::Display). Use [`as_str`](Self::as_str) only when
139/// serializing Receipt Access or storing the key securely.
140///
141/// The in-memory key bytes are zeroized on drop as defense-in-depth. This is
142/// not a complete scrub: the key is serialized verbatim into the Receipt Access
143/// wire message, so copies necessarily exist outside this type.
144#[derive(Clone, PartialEq, Eq)]
145pub struct ReceiptDecryptionKey(String);
146
147impl ReceiptDecryptionKey {
148    /// Generate a fresh 256-bit Receipt Decryption Key encoded as base64url.
149    pub fn generate() -> Self {
150        let mut key = XChaCha20Poly1305::generate_key(&mut OsRng);
151        let encoded = URL_SAFE_NO_PAD.encode(key.as_slice());
152        // Scrub the raw key bytes from the transient buffer; the base64url form
153        // is the only surviving copy, guarded by this type's Drop impl.
154        key.as_mut_slice().zeroize();
155        Self(encoded)
156    }
157
158    /// Validate and construct a Receipt Decryption Key from base64url text.
159    pub fn new(key: impl Into<String>) -> Result<Self> {
160        // AUDITOR NOTE (defense-in-depth for candidate key material): both the
161        // input candidate `String` and the decode buffer are scrubbed on every
162        // return path, not just on success. `Self` is only constructed on
163        // success, so this type's Drop-based zeroization does not cover the
164        // error paths; we scrub `key` explicitly before each early return. The
165        // decoded bytes live in a `Zeroizing` buffer, so any partial prefix a
166        // failing `decode_vec` writes before erroring is scrubbed on drop.
167        let mut key = key.into();
168        let mut decoded: Zeroizing<Vec<u8>> = Zeroizing::new(Vec::new());
169        // The base64 DecodeError can render an offending byte of the candidate
170        // key text; this is key material, so the error carries no decode
171        // detail at all, only static messages.
172        if URL_SAFE_NO_PAD.decode_vec(&key, &mut decoded).is_err() {
173            key.zeroize();
174            return Err(PaykitError::Validation(
175                "Receipt Decryption Key must be base64url".into(),
176            ));
177        }
178        if decoded.len() != 32 {
179            key.zeroize();
180            return Err(PaykitError::Validation(
181                "Receipt Decryption Key must decode to 32 bytes".into(),
182            ));
183        }
184        // Move the validated base64url form into the type; its Drop zeroizes it
185        // later. `decoded` scrubs its 32 bytes on drop at end of scope.
186        Ok(Self(key))
187    }
188
189    /// Access the raw base64url key material.
190    ///
191    /// Treat this value as secret; do not log it or include it in telemetry.
192    pub fn as_str(&self) -> &str {
193        &self.0
194    }
195
196    pub(super) fn bytes(&self) -> Result<[u8; 32]> {
197        // The base64 DecodeError can render an offending byte of the stored
198        // key text; this is key material, so drop the detail entirely rather
199        // than retaining it in `source`.
200        let mut decoded =
201            URL_SAFE_NO_PAD
202                .decode(&self.0)
203                .map_err(|_| PaykitError::InvalidData {
204                    context: "Receipt Decryption Key is not valid base64url".into(),
205                    source: None,
206                })?;
207        let key_bytes =
208            <[u8; 32]>::try_from(decoded.as_slice()).map_err(|_| PaykitError::InvalidData {
209                context: "Receipt Decryption Key must decode to 32 bytes".into(),
210                source: None,
211            });
212        // Scrub the transient decoded buffer; the returned array is the caller's
213        // responsibility and is scrubbed after cipher construction in crypto.rs.
214        decoded.zeroize();
215        key_bytes
216    }
217}
218
219impl AsRef<str> for ReceiptDecryptionKey {
220    fn as_ref(&self) -> &str {
221        &self.0
222    }
223}
224
225impl std::fmt::Debug for ReceiptDecryptionKey {
226    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
227        f.write_str("ReceiptDecryptionKey([redacted])")
228    }
229}
230
231impl std::fmt::Display for ReceiptDecryptionKey {
232    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233        f.write_str("[redacted Receipt Decryption Key]")
234    }
235}
236
237// AUDITOR NOTE (defense-in-depth, not a complete scrub): the Receipt Decryption
238// Key is serialized verbatim into the Receipt Access wire JSON (see
239// `receipt/wire.rs`), so copies of this secret necessarily exist outside this
240// type (network buffers, serialized messages, homeserver storage). This Drop
241// only clears the in-memory base64url `String` owned here; clones each zeroize
242// independently. It does not, and cannot, erase the key's full footprint.
243impl Drop for ReceiptDecryptionKey {
244    fn drop(&mut self) {
245        self.0.zeroize();
246    }
247}
248
249/// Prepared receipt artifacts ready to store and share.
250///
251/// Contains the plaintext Receipt, Encrypted Receipt payload, and Receipt
252/// Access descriptor.
253#[derive(Clone, PartialEq, Eq)]
254pub struct PreparedReceipt {
255    /// Canonical plaintext receipt before encryption.
256    pub receipt: Receipt,
257    /// Encrypted Receipt JSON to store at [`ReceiptAccess::location`].
258    pub encrypted_receipt: String,
259    /// Receipt Access descriptor to send to the counterparty.
260    pub access: ReceiptAccess,
261}
262
263impl fmt::Debug for PreparedReceipt {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        f.debug_struct("PreparedReceipt")
266            .field("receipt", &self.receipt)
267            .field(
268                "encrypted_receipt",
269                &format_args!("<redacted:{} bytes>", self.encrypted_receipt.len()),
270            )
271            .field("access", &self.access)
272            .finish()
273    }
274}
275
276/// Receipt Access descriptor sent over the existing Noise channel.
277#[derive(Clone, PartialEq, Eq)]
278pub struct ReceiptAccess {
279    /// Receipt Access message version. Currently always `1`.
280    pub version: u8,
281    /// Private message kind. Currently always [`PrivateMessageKind::ReceiptAccess`].
282    pub kind: PrivateMessageKind,
283    /// Event ID for idempotent processing.
284    pub event_id: EventId,
285    /// Receipt artifact identifier used to derive the Receipt Location path.
286    pub receipt_id: ReceiptId,
287    /// Payment Reference for the receipt.
288    pub payment_reference: PaymentReference,
289    /// Optional Payment Request ID this Receipt Access corresponds to.
290    pub payment_request_id: Option<PaymentRequestId>,
291    /// Optional Billing Period for recurring Payment Request receipts.
292    pub billing_period: Option<BillingPeriod>,
293    /// Homeserver path of the Encrypted Receipt.
294    pub location: String,
295    /// Symmetric key needed to decrypt the Receipt.
296    pub key: ReceiptDecryptionKey,
297}
298
299impl fmt::Debug for ReceiptAccess {
300    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
301        f.debug_struct("ReceiptAccess")
302            .field("version", &self.version)
303            .field("kind", &self.kind)
304            .field("event_id", &self.event_id)
305            .field("receipt_id", &self.receipt_id)
306            .field("payment_reference", &"<redacted>")
307            .field("payment_request_id", &self.payment_request_id)
308            .field("billing_period", &self.billing_period)
309            .field("location", &"<redacted>")
310            .field("key", &"<redacted>")
311            .finish()
312    }
313}
314
315/// A recognized Receipt Access Event Message plus the raw JSON payload received
316/// from the Encrypted Link.
317#[derive(Clone, PartialEq)]
318pub struct ReceiptAccessEventMessage {
319    /// Private message kind selected from the message header.
320    pub kind: PrivateMessageKind,
321    /// Application that issued this Receipt Access event.
322    pub app_id: Option<crate::PaykitAppId>,
323    /// Parsed top-level Event ID when present and valid.
324    pub event_id: Option<EventId>,
325    /// Parsed top-level Receipt ID when present and valid.
326    pub receipt_id: Option<ReceiptId>,
327    /// Raw JSON plaintext as sent over the Encrypted Link.
328    pub raw_json: String,
329    /// Parsed Receipt Access, or an error string explaining why this recognized
330    /// message failed structural validation.
331    pub access: std::result::Result<ReceiptAccess, String>,
332}
333
334impl fmt::Debug for ReceiptAccessEventMessage {
335    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
336        f.debug_struct("ReceiptAccessEventMessage")
337            .field("kind", &self.kind)
338            .field("app_id", &self.app_id)
339            .field("event_id", &self.event_id)
340            .field("receipt_id", &self.receipt_id)
341            .field(
342                "raw_json",
343                &format_args!("<redacted:{} bytes>", self.raw_json.len()),
344            )
345            .field("parsed", &self.access.is_ok())
346            .field("validation_error", &self.validation_error())
347            .finish()
348    }
349}
350
351impl ReceiptAccessEventMessage {
352    /// Return the Private Message Kind for this event message.
353    pub fn kind(&self) -> PrivateMessageKind {
354        self.kind
355    }
356
357    /// Return the application that issued this event, when valid.
358    pub fn app_id(&self) -> Option<&crate::PaykitAppId> {
359        self.app_id.as_ref()
360    }
361
362    /// Whether the recognized event message parsed successfully.
363    pub fn is_valid(&self) -> bool {
364        self.access.is_ok()
365    }
366
367    /// Access the parsed Receipt Access when structural validation succeeded.
368    pub fn parsed_access(&self) -> Option<&ReceiptAccess> {
369        self.access.as_ref().ok()
370    }
371
372    /// Access the validation error when structural validation failed.
373    pub fn validation_error(&self) -> Option<&str> {
374        self.access.as_ref().err().map(String::as_str)
375    }
376
377    /// Access the Event ID.
378    ///
379    /// Returns `None` when the recognized message is malformed and the Event ID
380    /// could not be parsed as a valid Event ID.
381    pub fn event_id(&self) -> Option<&EventId> {
382        self.event_id.as_ref()
383    }
384
385    /// Access the Receipt ID carried by this Receipt Access event.
386    ///
387    /// Returns `None` when the recognized message is malformed and the Receipt
388    /// ID could not be parsed as a valid Receipt ID.
389    pub fn receipt_id(&self) -> Option<&ReceiptId> {
390        self.receipt_id.as_ref()
391    }
392}
393
394fn validate_request_context(
395    payment_request_id: Option<&PaymentRequestId>,
396    billing_period: Option<&BillingPeriod>,
397    label: &str,
398) -> Result<()> {
399    if payment_request_id.is_none() && billing_period.is_some() {
400        return Err(PaykitError::Validation(format!(
401            "{label} billing_period requires payment_request_id"
402        )));
403    }
404    if let Some(period) = billing_period {
405        period.validate_with_label(label)?;
406    }
407    Ok(())
408}
409
410impl ReceiptDraft {
411    pub(crate) fn validate_request_context(&self) -> Result<()> {
412        validate_request_context(
413            self.payment_request_id.as_ref(),
414            self.billing_period.as_ref(),
415            "Receipt Draft",
416        )
417    }
418}
419
420impl Receipt {
421    pub(crate) fn validate_request_context(&self) -> Result<()> {
422        validate_request_context(
423            self.payment_request_id.as_ref(),
424            self.billing_period.as_ref(),
425            "Receipt",
426        )
427    }
428}
429
430impl ReceiptAccess {
431    pub(crate) fn validate_request_context(&self) -> Result<()> {
432        validate_request_context(
433            self.payment_request_id.as_ref(),
434            self.billing_period.as_ref(),
435            "Receipt Access",
436        )
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn receipt_debug_redacts_payment_reference() {
446        let payment_reference = PaymentReference::new("invoice-secret-123").unwrap();
447        let draft = ReceiptDraft {
448            receipt_id: Some(ReceiptId::new_v4()),
449            payment_reference: payment_reference.clone(),
450            payment_request_id: Some(PaymentRequestId::new_v4()),
451            billing_period: None,
452            payment_endpoint_identifier: Some(
453                PaymentEndpointIdentifier::new("btc-lightning-bolt11").unwrap(),
454            ),
455            amount: Some(PaymentAmount::new("0.001".to_string(), "btc".to_string()).unwrap()),
456            metadata: JsonMap::from_iter([(
457                "note".to_string(),
458                JsonValue::String("private receipt note".to_string()),
459            )]),
460        };
461        let receipt = Receipt {
462            receipt_id: draft.receipt_id.clone().unwrap(),
463            payment_reference,
464            payment_request_id: draft.payment_request_id.clone(),
465            billing_period: None,
466            recipient_public_key: pubky::Keypair::random().public_key().clone(),
467            payment_endpoint_identifier: draft.payment_endpoint_identifier.clone(),
468            amount: draft.amount.clone(),
469            metadata: draft.metadata.clone(),
470        };
471
472        let access = ReceiptAccess {
473            version: 1,
474            kind: PrivateMessageKind::ReceiptAccess,
475            event_id: EventId::new_v4(),
476            receipt_id: receipt.receipt_id.clone(),
477            payment_reference: receipt.payment_reference.clone(),
478            payment_request_id: receipt.payment_request_id.clone(),
479            billing_period: None,
480            location: ReceiptAccess::location_for(&receipt.receipt_id),
481            key: ReceiptDecryptionKey::generate(),
482        };
483        let prepared = PreparedReceipt {
484            receipt: receipt.clone(),
485            encrypted_receipt: "encrypted-secret".into(),
486            access: access.clone(),
487        };
488
489        for debug in [
490            format!("{draft:?}"),
491            format!("{receipt:?}"),
492            format!("{access:?}"),
493            format!("{prepared:?}"),
494        ] {
495            assert!(!debug.contains("invoice-secret-123"));
496            assert!(!debug.contains("private receipt note"));
497            assert!(!debug.contains("encrypted-secret"));
498            assert!(!debug.contains(access.key.as_str()));
499            assert!(!debug.contains(&access.location));
500        }
501    }
502
503    #[test]
504    fn test_receipt_decryption_key_new_rejects_non_32_byte_material() {
505        // Scrubbing the decoded buffer must not disturb length validation.
506        let short = URL_SAFE_NO_PAD.encode([7u8; 16]);
507        assert!(matches!(
508            ReceiptDecryptionKey::new(short).unwrap_err(),
509            PaykitError::Validation(_)
510        ));
511    }
512
513    #[test]
514    fn test_receipt_decryption_key_new_rejects_non_base64url() {
515        assert!(matches!(
516            ReceiptDecryptionKey::new("not valid base64!!").unwrap_err(),
517            PaykitError::Validation(_)
518        ));
519    }
520
521    #[test]
522    fn test_receipt_decryption_key_bytes_roundtrip() {
523        // Re-encoding the decoded bytes must reproduce the stored form, proving
524        // the transient-buffer scrub does not corrupt the key.
525        let key = ReceiptDecryptionKey::generate();
526        let bytes = key.bytes().unwrap();
527        assert_eq!(URL_SAFE_NO_PAD.encode(bytes), key.as_str());
528    }
529
530    #[test]
531    fn test_receipt_decryption_key_clone_survives_dropped_original() {
532        // Clones own independent heap buffers, so the original's Drop-time
533        // zeroize must not affect a surviving clone.
534        let expected;
535        let clone;
536        {
537            let original = ReceiptDecryptionKey::generate();
538            expected = original.as_str().to_string();
539            clone = original.clone();
540            // `original` is dropped (and zeroized) at the end of this scope.
541        }
542        assert_eq!(clone.as_str(), expected);
543        assert_eq!(clone.bytes().unwrap().len(), 32);
544    }
545}