Skip to main content

dynamic_waas_sdk/
webhook.rs

1//! Decrypt delegated-wallet webhook payloads.
2//!
3//! Mirrors `@dynamic-labs-wallet/node`'s `decryptDelegatedWebhookData`.
4//! The Dynamic backend emits two encrypted blobs in delegated-wallet
5//! webhooks — the encrypted server key share and the encrypted per-wallet
6//! API key. Both use a hybrid scheme:
7//!
8//!   * a fresh AES-256-GCM key is generated server-side,
9//!   * the payload is encrypted with that key under AES-GCM,
10//!   * the AES key itself is encrypted with the customer's RSA public key
11//!     using RSA-OAEP-SHA256.
12//!
13//! The customer's RSA private key is the secret that gates both
14//! decryptions; this function does the unwrap end-to-end.
15
16use 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
27/// Algorithm identifiers Dynamic's delegation webhook currently emits.
28/// Both name the same hybrid scheme (RSA-OAEP-SHA256 unwrapping an
29/// AES-256-GCM payload key) — the production server has used both
30/// labels over time. We hard-fail on any other value to defend against
31/// algorithm-substitution / downgrade attacks. The current decrypt path
32/// is fixed to RSA-OAEP-SHA256 + AES-256-GCM and ignores the field for
33/// selection; validating it at the perimeter keeps future changes honest.
34const SUPPORTED_ALGS: &[&str] = &["HYBRID-RSA-AES-256", "RSA-OAEP"];
35
36/// Wire format of one encrypted webhook field.
37///
38/// Matches `EncryptedDelegatedPayload` in the Node SDK exactly:
39///   * `alg`   — algorithm identifier, validated against [`SUPPORTED_ALGS`]
40///   * `iv`    — AES-GCM 96-bit nonce, base64url
41///   * `ct`    — AES-GCM ciphertext (without the auth tag), base64url
42///   * `tag`   — AES-GCM 128-bit auth tag, base64url
43///   * `ek`    — RSA-OAEP-SHA256 wrapped AES key, base64url
44///   * `kid`   — optional key identifier for key rotation
45#[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/// Result of decrypting a delegated-wallet webhook.
58///
59/// Both fields hold long-lived secret material. The custom `Debug` impl
60/// redacts them so accidental `tracing::debug!({:?})` or panic
61/// `unwrap`s can't spill the wallet API key or share secret into logs.
62#[derive(Clone)]
63pub struct DecryptedWebhookData {
64    /// The server's share of the wallet key. Pass this in as
65    /// `external_server_key_shares` to `DelegatedWalletClient` operations.
66    pub server_key_share: ServerKeyShare,
67    /// The per-wallet delegated API key. Use as the `wallet_api_key`
68    /// constructor argument to `DelegatedWalletClient`.
69    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
81/// Decrypt the two encrypted blobs delivered in a delegated-wallet
82/// webhook (the server key share + the per-wallet API key) using the
83/// customer's RSA private key.
84///
85/// `rsa_private_key_pem` is a PKCS#8 PEM-encoded RSA private key — the
86/// counterpart to the public key configured on the Dynamic environment
87/// settings.
88///
89/// # Errors
90/// Returns [`Error::InvalidArgument`] on:
91///   * malformed PEM
92///   * base64-url decode failure on any field
93///   * RSA-OAEP unwrap failure (wrong key, tampered `ek`)
94///   * AES-GCM auth-tag mismatch (tampered ciphertext)
95///   * non-UTF8 plaintext for the wallet API key
96///   * non-JSON / mismatched schema for the share payload
97pub 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    // RSA-OAEP-SHA256 unwrap the AES key.
138    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    // AES-256-GCM decrypt. The Node code stores `ct` and `tag` in separate
155    // base64url fields; the `aes-gcm` crate's `decrypt` expects them
156    // concatenated as `ct || tag`, so we glue them back together.
157    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
179/// Parse the decrypted share JSON, accepting either `snake_case` or
180/// `camelCase` field names (the wire format from Dynamic's webhook may
181/// differ from the Rust SDK's canonical `snake_case`).
182fn 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    /// Encrypt a single plaintext using the same wire format the Dynamic
213    /// webhook uses, given an RSA public key.
214    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        // Wrap the AES key with RSA-OAEP-SHA256.
222        let ek = rsa_pub
223            .encrypt(&mut rsa::rand_core::OsRng, Oaep::new::<Sha256>(), &aes_key)
224            .unwrap();
225
226        // AES-256-GCM encrypt.
227        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        // The aes-gcm crate concatenates `ct||tag` (tag is 16 bytes at the end).
238        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        // Webhook server may emit camelCase; we accept both via serde alias.
278        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        // Flip a bit in the ciphertext.
315        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        // Defends against algorithm-substitution / downgrade attacks: a
331        // tampered webhook that swaps `alg` to something we don't speak
332        // must be rejected at the perimeter, before any RSA or AES work.
333        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(); // weaker / unsupported
338
339        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        // Defense in depth: even though we never log this struct
352        // ourselves, callers using `tracing::debug!({:?}, decrypted)`
353        // or an `unwrap()` panic message must not leak the share or the
354        // wallet API key.
355        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}