1use aes_gcm::aead::{Aead, KeyInit, Payload};
17use aes_gcm::{Aes256Gcm, Key, Nonce};
18use base64::engine::general_purpose;
19use base64::Engine;
20use rsa::pkcs8::DecodePrivateKey;
21use rsa::{Oaep, RsaPrivateKey};
22use serde::Deserialize;
23use sha2::Sha256;
24
25use dynamic_waas_sdk_core::{Error, Result, ServerKeyShare};
26
27const SUPPORTED_ALGS: &[&str] = &["HYBRID-RSA-AES-256", "RSA-OAEP"];
35
36#[derive(Debug, Clone, Deserialize)]
46#[serde(deny_unknown_fields)]
47pub struct EncryptedDelegatedPayload {
48 pub alg: String,
49 pub iv: String,
50 pub ct: String,
51 pub tag: String,
52 pub ek: String,
53 #[serde(default)]
54 pub kid: Option<String>,
55}
56
57#[derive(Clone)]
63pub struct DecryptedWebhookData {
64 pub server_key_share: ServerKeyShare,
67 pub wallet_api_key: String,
70}
71
72impl std::fmt::Debug for DecryptedWebhookData {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 f.debug_struct("DecryptedWebhookData")
75 .field("server_key_share", &"<redacted>")
76 .field("wallet_api_key", &"<redacted>")
77 .finish()
78 }
79}
80
81pub fn decrypt_delegated_webhook_data(
98 rsa_private_key_pem: &str,
99 encrypted_server_key_share: &EncryptedDelegatedPayload,
100 encrypted_wallet_api_key: &EncryptedDelegatedPayload,
101) -> Result<DecryptedWebhookData> {
102 let private_key = RsaPrivateKey::from_pkcs8_pem(rsa_private_key_pem)
103 .map_err(|e| Error::InvalidArgument(format!("invalid RSA private key PEM: {e}")))?;
104
105 let share_plaintext = decrypt_one(&private_key, encrypted_server_key_share)?;
106 let api_key_plaintext = decrypt_one(&private_key, encrypted_wallet_api_key)?;
107
108 let share_json = std::str::from_utf8(&share_plaintext)
109 .map_err(|e| Error::InvalidArgument(format!("decrypted share is not valid UTF-8: {e}")))?;
110 let server_key_share: ServerKeyShare = parse_share_json(share_json)?;
111
112 let wallet_api_key = String::from_utf8(api_key_plaintext).map_err(|e| {
113 Error::InvalidArgument(format!("decrypted wallet API key is not valid UTF-8: {e}"))
114 })?;
115
116 Ok(DecryptedWebhookData {
117 server_key_share,
118 wallet_api_key,
119 })
120}
121
122fn decrypt_one(
123 private_key: &RsaPrivateKey,
124 payload: &EncryptedDelegatedPayload,
125) -> Result<Vec<u8>> {
126 if !SUPPORTED_ALGS.contains(&payload.alg.as_str()) {
127 return Err(Error::InvalidArgument(format!(
128 "unsupported delegation payload algorithm `{}`; expected one of {:?}",
129 payload.alg, SUPPORTED_ALGS
130 )));
131 }
132 let ek = base64_url_decode(&payload.ek, "ek")?;
133 let iv = base64_url_decode(&payload.iv, "iv")?;
134 let ct = base64_url_decode(&payload.ct, "ct")?;
135 let tag = base64_url_decode(&payload.tag, "tag")?;
136
137 let aes_key_bytes = private_key
139 .decrypt(Oaep::new::<Sha256>(), &ek)
140 .map_err(|e| Error::InvalidArgument(format!("RSA-OAEP unwrap failed: {e}")))?;
141 if aes_key_bytes.len() != 32 {
142 return Err(Error::InvalidArgument(format!(
143 "unwrapped AES key has wrong length: {} (expected 32)",
144 aes_key_bytes.len()
145 )));
146 }
147 if iv.len() != 12 {
148 return Err(Error::InvalidArgument(format!(
149 "AES-GCM nonce has wrong length: {} (expected 12)",
150 iv.len()
151 )));
152 }
153
154 let mut ct_with_tag = ct;
158 ct_with_tag.extend_from_slice(&tag);
159
160 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&aes_key_bytes));
161 let nonce = Nonce::from_slice(&iv);
162 cipher
163 .decrypt(
164 nonce,
165 Payload {
166 msg: &ct_with_tag,
167 aad: &[],
168 },
169 )
170 .map_err(|e| Error::InvalidArgument(format!("AES-GCM decrypt failed: {e}")))
171}
172
173fn base64_url_decode(input: &str, field: &str) -> Result<Vec<u8>> {
174 general_purpose::URL_SAFE_NO_PAD
175 .decode(input.trim_end_matches('='))
176 .map_err(|e| Error::InvalidArgument(format!("invalid base64url in `{field}`: {e}")))
177}
178
179fn parse_share_json(s: &str) -> Result<ServerKeyShare> {
183 #[derive(Deserialize)]
184 struct Wire {
185 #[serde(alias = "keyShareId")]
186 key_share_id: String,
187 #[serde(alias = "secretShare")]
188 secret_share: String,
189 #[serde(default, alias = "pubKey")]
190 pub_key: Option<String>,
191 }
192
193 let wire: Wire = serde_json::from_str(s).map_err(|e| {
194 Error::InvalidArgument(format!(
195 "decrypted share is not a valid ServerKeyShare JSON: {e}"
196 ))
197 })?;
198 let mut sks = ServerKeyShare::new(wire.key_share_id, wire.secret_share);
199 if let Some(pk) = wire.pub_key {
200 sks = sks.with_pub_key(pk);
201 }
202 Ok(sks)
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use rand::TryRngCore;
209 use rsa::pkcs8::{EncodePrivateKey, LineEnding};
210 use rsa::RsaPublicKey;
211
212 fn encrypt_for_test(rsa_pub: &RsaPublicKey, plaintext: &[u8]) -> EncryptedDelegatedPayload {
215 let mut rng = rand::rngs::OsRng;
216 let mut aes_key = [0u8; 32];
217 rng.try_fill_bytes(&mut aes_key).unwrap();
218 let mut iv = [0u8; 12];
219 rng.try_fill_bytes(&mut iv).unwrap();
220
221 let ek = rsa_pub
223 .encrypt(&mut rsa::rand_core::OsRng, Oaep::new::<Sha256>(), &aes_key)
224 .unwrap();
225
226 let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&aes_key));
228 let ciphertext_and_tag = cipher
229 .encrypt(
230 Nonce::from_slice(&iv),
231 Payload {
232 msg: plaintext,
233 aad: &[],
234 },
235 )
236 .unwrap();
237 let split_at = ciphertext_and_tag.len() - 16;
239 let ct = &ciphertext_and_tag[..split_at];
240 let tag = &ciphertext_and_tag[split_at..];
241
242 EncryptedDelegatedPayload {
243 alg: SUPPORTED_ALGS[0].into(),
244 iv: general_purpose::URL_SAFE_NO_PAD.encode(iv),
245 ct: general_purpose::URL_SAFE_NO_PAD.encode(ct),
246 tag: general_purpose::URL_SAFE_NO_PAD.encode(tag),
247 ek: general_purpose::URL_SAFE_NO_PAD.encode(&ek),
248 kid: None,
249 }
250 }
251
252 fn fresh_keypair() -> (String, RsaPublicKey) {
253 let priv_key = RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 2048).unwrap();
254 let pub_key = RsaPublicKey::from(&priv_key);
255 let pem = priv_key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string();
256 (pem, pub_key)
257 }
258
259 #[test]
260 fn round_trip_snake_case() {
261 let (pem, pub_key) = fresh_keypair();
262 let share_json = r#"{"key_share_id":"ks-1","secret_share":"deadbeef","pub_key":"0x04abc"}"#;
263 let wallet_api_key = "test-fixture-wallet-key-snake";
264
265 let enc_share = encrypt_for_test(&pub_key, share_json.as_bytes());
266 let enc_wapi = encrypt_for_test(&pub_key, wallet_api_key.as_bytes());
267
268 let out = decrypt_delegated_webhook_data(&pem, &enc_share, &enc_wapi).unwrap();
269 assert_eq!(out.server_key_share.key_share_id, "ks-1");
270 assert_eq!(out.server_key_share.secret_share, "deadbeef");
271 assert_eq!(out.server_key_share.pub_key.as_deref(), Some("0x04abc"));
272 assert_eq!(out.wallet_api_key, wallet_api_key);
273 }
274
275 #[test]
276 fn round_trip_camel_case() {
277 let (pem, pub_key) = fresh_keypair();
279 let share_json = r#"{"keyShareId":"ks-2","secretShare":"feedface"}"#;
280 let wallet_api_key = "test-fixture-wallet-key-camel";
281
282 let enc_share = encrypt_for_test(&pub_key, share_json.as_bytes());
283 let enc_wapi = encrypt_for_test(&pub_key, wallet_api_key.as_bytes());
284
285 let out = decrypt_delegated_webhook_data(&pem, &enc_share, &enc_wapi).unwrap();
286 assert_eq!(out.server_key_share.key_share_id, "ks-2");
287 assert_eq!(out.server_key_share.secret_share, "feedface");
288 assert!(out.server_key_share.pub_key.is_none());
289 assert_eq!(out.wallet_api_key, wallet_api_key);
290 }
291
292 #[test]
293 fn wrong_private_key_fails_cleanly() {
294 let (_pem_a, pub_key_a) = fresh_keypair();
295 let (pem_b, _pub_key_b) = fresh_keypair();
296 let share_json = r#"{"key_share_id":"ks","secret_share":"x"}"#;
297 let enc_share = encrypt_for_test(&pub_key_a, share_json.as_bytes());
298 let enc_wapi = encrypt_for_test(&pub_key_a, b"key");
299
300 let err = decrypt_delegated_webhook_data(&pem_b, &enc_share, &enc_wapi).unwrap_err();
301 match err {
302 Error::InvalidArgument(msg) => assert!(msg.contains("RSA-OAEP")),
303 other => panic!("expected InvalidArgument, got {other:?}"),
304 }
305 }
306
307 #[test]
308 fn tampered_ciphertext_fails_cleanly() {
309 let (pem, pub_key) = fresh_keypair();
310 let share_json = r#"{"key_share_id":"ks","secret_share":"x"}"#;
311 let mut enc_share = encrypt_for_test(&pub_key, share_json.as_bytes());
312 let enc_wapi = encrypt_for_test(&pub_key, b"key");
313
314 let mut ct_bytes = general_purpose::URL_SAFE_NO_PAD
316 .decode(&enc_share.ct)
317 .unwrap();
318 ct_bytes[0] ^= 0x01;
319 enc_share.ct = general_purpose::URL_SAFE_NO_PAD.encode(&ct_bytes);
320
321 let err = decrypt_delegated_webhook_data(&pem, &enc_share, &enc_wapi).unwrap_err();
322 match err {
323 Error::InvalidArgument(msg) => assert!(msg.contains("AES-GCM")),
324 other => panic!("expected InvalidArgument, got {other:?}"),
325 }
326 }
327
328 #[test]
329 fn unexpected_alg_fails_before_any_crypto() {
330 let (pem, pub_key) = fresh_keypair();
334 let share_json = r#"{"key_share_id":"ks","secret_share":"x"}"#;
335 let mut enc_share = encrypt_for_test(&pub_key, share_json.as_bytes());
336 let enc_wapi = encrypt_for_test(&pub_key, b"key");
337 enc_share.alg = "RSA-OAEP-256+A128GCM".into(); let err = decrypt_delegated_webhook_data(&pem, &enc_share, &enc_wapi).unwrap_err();
340 match err {
341 Error::InvalidArgument(msg) => assert!(
342 msg.contains("unsupported delegation payload algorithm"),
343 "message should mention the rejected algorithm: {msg}"
344 ),
345 other => panic!("expected InvalidArgument, got {other:?}"),
346 }
347 }
348
349 #[test]
350 fn decrypted_data_debug_redacts_both_fields() {
351 let data = DecryptedWebhookData {
356 server_key_share: ServerKeyShare::new("ks", "super-secret-share-bytes"),
357 wallet_api_key: "super-secret-wallet-api-key".to_string(),
358 };
359 let dbg = format!("{data:?}");
360 assert!(
361 !dbg.contains("super-secret-share-bytes"),
362 "share secret leaked via Debug: {dbg}"
363 );
364 assert!(
365 !dbg.contains("super-secret-wallet-api-key"),
366 "wallet api key leaked via Debug: {dbg}"
367 );
368 assert!(dbg.contains("redacted"));
369 }
370
371 #[test]
372 fn malformed_pem_fails_cleanly() {
373 let dummy = EncryptedDelegatedPayload {
374 alg: "x".into(),
375 iv: String::new(),
376 ct: String::new(),
377 tag: String::new(),
378 ek: String::new(),
379 kid: None,
380 };
381 let err = decrypt_delegated_webhook_data("not a pem", &dummy, &dummy).unwrap_err();
382 match err {
383 Error::InvalidArgument(msg) => assert!(msg.contains("RSA private key PEM")),
384 other => panic!("expected InvalidArgument, got {other:?}"),
385 }
386 }
387}