Skip to main content

paykit_lib/receipt/
crypto.rs

1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
2use chacha20poly1305::{
3    aead::{Aead, AeadCore, KeyInit, OsRng},
4    XChaCha20Poly1305,
5};
6
7use zeroize::Zeroize;
8
9use crate::validation::invalid_plaintext_json;
10use crate::{PaykitError, Result};
11
12use super::{
13    wire::{EncryptedReceiptWire, ReceiptWire},
14    Receipt, ReceiptAccess, ReceiptDecryptionKey, ENCRYPTED_RECEIPT_MAX_BYTES,
15    RECEIPT_ENCRYPTION_ALGORITHM,
16};
17
18impl Receipt {
19    pub(super) fn aad_for_location(location: &str) -> String {
20        format!("paykit.receipt.v1:{location}")
21    }
22
23    /// Encrypt this receipt for storage at its canonical Receipt Location path
24    /// using `key`.
25    ///
26    /// The location path is derived from the Receipt ID and authenticated as
27    /// AEAD associated data; callers must use that same canonical path when
28    /// decrypting.
29    pub fn encrypt(&self, key: &ReceiptDecryptionKey) -> Result<String> {
30        let location = ReceiptAccess::location_for(&self.receipt_id);
31        self.encrypt_for_location(key, &location)
32    }
33
34    pub(crate) fn encrypt_for_location(
35        &self,
36        key: &ReceiptDecryptionKey,
37        location: &str,
38    ) -> Result<String> {
39        self.validate_request_context()?;
40        if let Some(amount) = &self.amount {
41            amount.validate_with_label("Receipt amount")?;
42        }
43        if ReceiptAccess::location_for(&self.receipt_id) != location {
44            return Err(PaykitError::Validation(
45                "Receipt Location does not match Receipt ID".into(),
46            ));
47        }
48        let mut key_bytes = key.bytes()?;
49        let cipher = XChaCha20Poly1305::new((&key_bytes).into());
50        // Scrub the raw key from the stack; the cipher holds its own key schedule.
51        key_bytes.zeroize();
52        let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
53        let plaintext = serde_json::to_vec(&ReceiptWire::from(self)).map_err(|err| {
54            PaykitError::InvalidData {
55                context: format!("failed to serialize receipt JSON: {err}"),
56                source: Some(err.into()),
57            }
58        })?;
59        let ciphertext = cipher
60            .encrypt(
61                &nonce,
62                chacha20poly1305::aead::Payload {
63                    msg: &plaintext,
64                    aad: Self::aad_for_location(location).as_bytes(),
65                },
66            )
67            .map_err(|err| PaykitError::InvalidData {
68                context: format!("failed to encrypt receipt: {err}"),
69                source: None,
70            })?;
71        let wire = EncryptedReceiptWire {
72            version: 1,
73            kind: "paykit.receipt.encrypted".to_string(),
74            algorithm: RECEIPT_ENCRYPTION_ALGORITHM.to_string(),
75            nonce: URL_SAFE_NO_PAD.encode(nonce),
76            ciphertext: URL_SAFE_NO_PAD.encode(ciphertext),
77        };
78        let encrypted_json =
79            serde_json::to_string(&wire).map_err(|err| PaykitError::InvalidData {
80                context: format!("failed to serialize encrypted receipt JSON: {err}"),
81                source: Some(err.into()),
82            })?;
83        if encrypted_json.len() > ENCRYPTED_RECEIPT_MAX_BYTES {
84            return Err(PaykitError::Validation(format!(
85                "Encrypted Receipt must not exceed {ENCRYPTED_RECEIPT_MAX_BYTES} bytes"
86            )));
87        }
88        Ok(encrypted_json)
89    }
90
91    /// Decrypt an Encrypted Receipt fetched from a homeserver.
92    ///
93    /// `key` and `location` normally come from a [`ReceiptAccess`] message. The
94    /// location path is authenticated as AEAD associated data and the decrypted
95    /// Receipt ID must match the canonical path.
96    pub fn decrypt(
97        encrypted_json: &str,
98        key: &ReceiptDecryptionKey,
99        location: &str,
100    ) -> Result<Self> {
101        if encrypted_json.len() > ENCRYPTED_RECEIPT_MAX_BYTES {
102            return Err(PaykitError::InvalidData {
103                context: format!("Encrypted Receipt exceeds {ENCRYPTED_RECEIPT_MAX_BYTES} bytes"),
104                source: None,
105            });
106        }
107        // The serde error can embed fragments of the fetched document; keep the
108        // context static and leave the detail in `source`, which stays local.
109        let wire: EncryptedReceiptWire =
110            serde_json::from_str(encrypted_json).map_err(|err| PaykitError::InvalidData {
111                context: "failed to parse encrypted receipt JSON".into(),
112                source: Some(err.into()),
113            })?;
114        if wire.version != 1
115            || wire.kind != "paykit.receipt.encrypted"
116            || wire.algorithm != RECEIPT_ENCRYPTION_ALGORITHM
117        {
118            // The offending values are fetched-document content; keep the
119            // context static so they never cross the FFI as exception text.
120            return Err(PaykitError::InvalidData {
121                context: "unsupported encrypted receipt envelope version/kind/algorithm".into(),
122                source: None,
123            });
124        }
125        // base64 DecodeError Display renders the offending byte and offset of
126        // the fetched document; keep these contexts static and leave the
127        // detail in `source`, which stays local. The nonce-length echo is
128        // likewise fetched-document derived, so it stays static too.
129        let nonce = URL_SAFE_NO_PAD
130            .decode(wire.nonce)
131            .map_err(|err| PaykitError::InvalidData {
132                context: "encrypted receipt nonce is not valid base64url".into(),
133                source: Some(err.into()),
134            })?;
135        let ciphertext =
136            URL_SAFE_NO_PAD
137                .decode(wire.ciphertext)
138                .map_err(|err| PaykitError::InvalidData {
139                    context: "encrypted receipt ciphertext is not valid base64url".into(),
140                    source: Some(err.into()),
141                })?;
142        if nonce.len() != 24 {
143            return Err(PaykitError::InvalidData {
144                context: "encrypted receipt nonce must be 24 bytes".into(),
145                source: None,
146            });
147        }
148        let mut key_bytes = key.bytes()?;
149        let cipher = XChaCha20Poly1305::new((&key_bytes).into());
150        // Scrub the raw key from the stack; the cipher holds its own key schedule.
151        key_bytes.zeroize();
152        let plaintext = cipher
153            .decrypt(
154                nonce.as_slice().into(),
155                chacha20poly1305::aead::Payload {
156                    msg: &ciphertext,
157                    aad: Self::aad_for_location(location).as_bytes(),
158                },
159            )
160            .map_err(|err| PaykitError::InvalidData {
161                context: format!("failed to decrypt receipt: {err}"),
162                source: None,
163            })?;
164        // SECURITY / REDACTION: the serde error is derived from DECRYPTED
165        // receipt plaintext (its Display embeds field values on type
166        // mismatches), so it must not be folded into the context or kept as
167        // `source` -- this error crosses the FFI boundary as exception text.
168        let receipt_wire: ReceiptWire = serde_json::from_slice(&plaintext)
169            .map_err(|_| invalid_plaintext_json("failed to parse receipt plaintext JSON"))?;
170        let receipt = Self::try_from(receipt_wire)?;
171        if ReceiptAccess::location_for(&receipt.receipt_id) != location {
172            return Err(PaykitError::InvalidData {
173                context: "Receipt ID does not match Receipt Location".into(),
174                source: None,
175            });
176        }
177        Ok(receipt)
178    }
179}
180
181/// Decrypts an Encrypted Receipt fetched from a homeserver.
182///
183/// `encrypted_json` is the stored Encrypted Receipt JSON. `key` and `location`
184/// normally come from a [`ReceiptAccess`] message. The `location` path is
185/// authenticated as additional data, and the decrypted Receipt ID must match
186/// that path.
187///
188/// Receipt Decryption Keys are sensitive. [`ReceiptDecryptionKey`] redacts its
189/// `Debug` and `Display` output, but callers must still avoid logging raw values
190/// returned by [`ReceiptDecryptionKey::as_str`].
191///
192/// # Errors
193/// Returns [`PaykitError::InvalidData`] if the encrypted receipt is malformed,
194/// fails authenticated decryption, or decrypts to receipt data that does not
195/// match the Receipt Location.
196pub fn decrypt_receipt(
197    encrypted_json: &str,
198    key: &ReceiptDecryptionKey,
199    location: &str,
200) -> Result<Receipt> {
201    Receipt::decrypt(encrypted_json, key, location)
202}