use aes_gcm::aead::{Aead, KeyInit, Payload};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::engine::general_purpose;
use base64::Engine;
use rsa::pkcs8::DecodePrivateKey;
use rsa::{Oaep, RsaPrivateKey};
use serde::Deserialize;
use sha2::Sha256;
use dynamic_waas_sdk_core::{Error, Result, ServerKeyShare};
const SUPPORTED_ALGS: &[&str] = &["HYBRID-RSA-AES-256", "RSA-OAEP"];
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EncryptedDelegatedPayload {
pub alg: String,
pub iv: String,
pub ct: String,
pub tag: String,
pub ek: String,
#[serde(default)]
pub kid: Option<String>,
}
#[derive(Clone)]
pub struct DecryptedWebhookData {
pub server_key_share: ServerKeyShare,
pub wallet_api_key: String,
}
impl std::fmt::Debug for DecryptedWebhookData {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DecryptedWebhookData")
.field("server_key_share", &"<redacted>")
.field("wallet_api_key", &"<redacted>")
.finish()
}
}
pub fn decrypt_delegated_webhook_data(
rsa_private_key_pem: &str,
encrypted_server_key_share: &EncryptedDelegatedPayload,
encrypted_wallet_api_key: &EncryptedDelegatedPayload,
) -> Result<DecryptedWebhookData> {
let private_key = RsaPrivateKey::from_pkcs8_pem(rsa_private_key_pem)
.map_err(|e| Error::InvalidArgument(format!("invalid RSA private key PEM: {e}")))?;
let share_plaintext = decrypt_one(&private_key, encrypted_server_key_share)?;
let api_key_plaintext = decrypt_one(&private_key, encrypted_wallet_api_key)?;
let share_json = std::str::from_utf8(&share_plaintext)
.map_err(|e| Error::InvalidArgument(format!("decrypted share is not valid UTF-8: {e}")))?;
let server_key_share: ServerKeyShare = parse_share_json(share_json)?;
let wallet_api_key = String::from_utf8(api_key_plaintext).map_err(|e| {
Error::InvalidArgument(format!("decrypted wallet API key is not valid UTF-8: {e}"))
})?;
Ok(DecryptedWebhookData {
server_key_share,
wallet_api_key,
})
}
fn decrypt_one(
private_key: &RsaPrivateKey,
payload: &EncryptedDelegatedPayload,
) -> Result<Vec<u8>> {
if !SUPPORTED_ALGS.contains(&payload.alg.as_str()) {
return Err(Error::InvalidArgument(format!(
"unsupported delegation payload algorithm `{}`; expected one of {:?}",
payload.alg, SUPPORTED_ALGS
)));
}
let ek = base64_url_decode(&payload.ek, "ek")?;
let iv = base64_url_decode(&payload.iv, "iv")?;
let ct = base64_url_decode(&payload.ct, "ct")?;
let tag = base64_url_decode(&payload.tag, "tag")?;
let aes_key_bytes = private_key
.decrypt(Oaep::new::<Sha256>(), &ek)
.map_err(|e| Error::InvalidArgument(format!("RSA-OAEP unwrap failed: {e}")))?;
if aes_key_bytes.len() != 32 {
return Err(Error::InvalidArgument(format!(
"unwrapped AES key has wrong length: {} (expected 32)",
aes_key_bytes.len()
)));
}
if iv.len() != 12 {
return Err(Error::InvalidArgument(format!(
"AES-GCM nonce has wrong length: {} (expected 12)",
iv.len()
)));
}
let mut ct_with_tag = ct;
ct_with_tag.extend_from_slice(&tag);
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&aes_key_bytes));
let nonce = Nonce::from_slice(&iv);
cipher
.decrypt(
nonce,
Payload {
msg: &ct_with_tag,
aad: &[],
},
)
.map_err(|e| Error::InvalidArgument(format!("AES-GCM decrypt failed: {e}")))
}
fn base64_url_decode(input: &str, field: &str) -> Result<Vec<u8>> {
general_purpose::URL_SAFE_NO_PAD
.decode(input.trim_end_matches('='))
.map_err(|e| Error::InvalidArgument(format!("invalid base64url in `{field}`: {e}")))
}
fn parse_share_json(s: &str) -> Result<ServerKeyShare> {
#[derive(Deserialize)]
struct Wire {
#[serde(alias = "keyShareId")]
key_share_id: String,
#[serde(alias = "secretShare")]
secret_share: String,
#[serde(default, alias = "pubKey")]
pub_key: Option<String>,
}
let wire: Wire = serde_json::from_str(s).map_err(|e| {
Error::InvalidArgument(format!(
"decrypted share is not a valid ServerKeyShare JSON: {e}"
))
})?;
let mut sks = ServerKeyShare::new(wire.key_share_id, wire.secret_share);
if let Some(pk) = wire.pub_key {
sks = sks.with_pub_key(pk);
}
Ok(sks)
}
#[cfg(test)]
mod tests {
use super::*;
use rand::TryRngCore;
use rsa::pkcs8::{EncodePrivateKey, LineEnding};
use rsa::RsaPublicKey;
fn encrypt_for_test(rsa_pub: &RsaPublicKey, plaintext: &[u8]) -> EncryptedDelegatedPayload {
let mut rng = rand::rngs::OsRng;
let mut aes_key = [0u8; 32];
rng.try_fill_bytes(&mut aes_key).unwrap();
let mut iv = [0u8; 12];
rng.try_fill_bytes(&mut iv).unwrap();
let ek = rsa_pub
.encrypt(&mut rsa::rand_core::OsRng, Oaep::new::<Sha256>(), &aes_key)
.unwrap();
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&aes_key));
let ciphertext_and_tag = cipher
.encrypt(
Nonce::from_slice(&iv),
Payload {
msg: plaintext,
aad: &[],
},
)
.unwrap();
let split_at = ciphertext_and_tag.len() - 16;
let ct = &ciphertext_and_tag[..split_at];
let tag = &ciphertext_and_tag[split_at..];
EncryptedDelegatedPayload {
alg: SUPPORTED_ALGS[0].into(),
iv: general_purpose::URL_SAFE_NO_PAD.encode(iv),
ct: general_purpose::URL_SAFE_NO_PAD.encode(ct),
tag: general_purpose::URL_SAFE_NO_PAD.encode(tag),
ek: general_purpose::URL_SAFE_NO_PAD.encode(&ek),
kid: None,
}
}
fn fresh_keypair() -> (String, RsaPublicKey) {
let priv_key = RsaPrivateKey::new(&mut rsa::rand_core::OsRng, 2048).unwrap();
let pub_key = RsaPublicKey::from(&priv_key);
let pem = priv_key.to_pkcs8_pem(LineEnding::LF).unwrap().to_string();
(pem, pub_key)
}
#[test]
fn round_trip_snake_case() {
let (pem, pub_key) = fresh_keypair();
let share_json = r#"{"key_share_id":"ks-1","secret_share":"deadbeef","pub_key":"0x04abc"}"#;
let wallet_api_key = "test-fixture-wallet-key-snake";
let enc_share = encrypt_for_test(&pub_key, share_json.as_bytes());
let enc_wapi = encrypt_for_test(&pub_key, wallet_api_key.as_bytes());
let out = decrypt_delegated_webhook_data(&pem, &enc_share, &enc_wapi).unwrap();
assert_eq!(out.server_key_share.key_share_id, "ks-1");
assert_eq!(out.server_key_share.secret_share, "deadbeef");
assert_eq!(out.server_key_share.pub_key.as_deref(), Some("0x04abc"));
assert_eq!(out.wallet_api_key, wallet_api_key);
}
#[test]
fn round_trip_camel_case() {
let (pem, pub_key) = fresh_keypair();
let share_json = r#"{"keyShareId":"ks-2","secretShare":"feedface"}"#;
let wallet_api_key = "test-fixture-wallet-key-camel";
let enc_share = encrypt_for_test(&pub_key, share_json.as_bytes());
let enc_wapi = encrypt_for_test(&pub_key, wallet_api_key.as_bytes());
let out = decrypt_delegated_webhook_data(&pem, &enc_share, &enc_wapi).unwrap();
assert_eq!(out.server_key_share.key_share_id, "ks-2");
assert_eq!(out.server_key_share.secret_share, "feedface");
assert!(out.server_key_share.pub_key.is_none());
assert_eq!(out.wallet_api_key, wallet_api_key);
}
#[test]
fn wrong_private_key_fails_cleanly() {
let (_pem_a, pub_key_a) = fresh_keypair();
let (pem_b, _pub_key_b) = fresh_keypair();
let share_json = r#"{"key_share_id":"ks","secret_share":"x"}"#;
let enc_share = encrypt_for_test(&pub_key_a, share_json.as_bytes());
let enc_wapi = encrypt_for_test(&pub_key_a, b"key");
let err = decrypt_delegated_webhook_data(&pem_b, &enc_share, &enc_wapi).unwrap_err();
match err {
Error::InvalidArgument(msg) => assert!(msg.contains("RSA-OAEP")),
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[test]
fn tampered_ciphertext_fails_cleanly() {
let (pem, pub_key) = fresh_keypair();
let share_json = r#"{"key_share_id":"ks","secret_share":"x"}"#;
let mut enc_share = encrypt_for_test(&pub_key, share_json.as_bytes());
let enc_wapi = encrypt_for_test(&pub_key, b"key");
let mut ct_bytes = general_purpose::URL_SAFE_NO_PAD
.decode(&enc_share.ct)
.unwrap();
ct_bytes[0] ^= 0x01;
enc_share.ct = general_purpose::URL_SAFE_NO_PAD.encode(&ct_bytes);
let err = decrypt_delegated_webhook_data(&pem, &enc_share, &enc_wapi).unwrap_err();
match err {
Error::InvalidArgument(msg) => assert!(msg.contains("AES-GCM")),
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[test]
fn unexpected_alg_fails_before_any_crypto() {
let (pem, pub_key) = fresh_keypair();
let share_json = r#"{"key_share_id":"ks","secret_share":"x"}"#;
let mut enc_share = encrypt_for_test(&pub_key, share_json.as_bytes());
let enc_wapi = encrypt_for_test(&pub_key, b"key");
enc_share.alg = "RSA-OAEP-256+A128GCM".into();
let err = decrypt_delegated_webhook_data(&pem, &enc_share, &enc_wapi).unwrap_err();
match err {
Error::InvalidArgument(msg) => assert!(
msg.contains("unsupported delegation payload algorithm"),
"message should mention the rejected algorithm: {msg}"
),
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
#[test]
fn decrypted_data_debug_redacts_both_fields() {
let data = DecryptedWebhookData {
server_key_share: ServerKeyShare::new("ks", "super-secret-share-bytes"),
wallet_api_key: "super-secret-wallet-api-key".to_string(),
};
let dbg = format!("{data:?}");
assert!(
!dbg.contains("super-secret-share-bytes"),
"share secret leaked via Debug: {dbg}"
);
assert!(
!dbg.contains("super-secret-wallet-api-key"),
"wallet api key leaked via Debug: {dbg}"
);
assert!(dbg.contains("redacted"));
}
#[test]
fn malformed_pem_fails_cleanly() {
let dummy = EncryptedDelegatedPayload {
alg: "x".into(),
iv: String::new(),
ct: String::new(),
tag: String::new(),
ek: String::new(),
kid: None,
};
let err = decrypt_delegated_webhook_data("not a pem", &dummy, &dummy).unwrap_err();
match err {
Error::InvalidArgument(msg) => assert!(msg.contains("RSA private key PEM")),
other => panic!("expected InvalidArgument, got {other:?}"),
}
}
}