paykit_lib/receipt/
crypto.rs1use 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 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 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 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 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 return Err(PaykitError::InvalidData {
121 context: "unsupported encrypted receipt envelope version/kind/algorithm".into(),
122 source: None,
123 });
124 }
125 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 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 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
181pub fn decrypt_receipt(
197 encrypted_json: &str,
198 key: &ReceiptDecryptionKey,
199 location: &str,
200) -> Result<Receipt> {
201 Receipt::decrypt(encrypted_json, key, location)
202}