From f9203e077e017a80db1763925d49b303f89e4ee7 Mon Sep 17 00:00:00 2001 From: huangping Date: Mon, 18 May 2026 22:05:36 +0800 Subject: [PATCH] feat(rust): add crypto module (HKDF + AES-256-GCM + RSA verify) --- native/craft-core/Cargo.toml | 3 + native/craft-core/src/crypto.rs | 144 ++++++++++++++++++++++++++++++++ native/craft-core/src/lib.rs | 1 + 3 files changed, 148 insertions(+) create mode 100644 native/craft-core/src/crypto.rs diff --git a/native/craft-core/Cargo.toml b/native/craft-core/Cargo.toml index 7bf6f2b..20c9640 100644 --- a/native/craft-core/Cargo.toml +++ b/native/craft-core/Cargo.toml @@ -24,6 +24,9 @@ chrono = { version = "0.4", features = ["serde"] } [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.52", features = ["Win32_System_Diagnostics_Debug"], optional = true } +[dev-dependencies] +rand = "0.8" + [build-dependencies] sha2 = "0.10" diff --git a/native/craft-core/src/crypto.rs b/native/craft-core/src/crypto.rs new file mode 100644 index 0000000..67ad696 --- /dev/null +++ b/native/craft-core/src/crypto.rs @@ -0,0 +1,144 @@ +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Nonce}; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hkdf::Hkdf; +use rsa::signature::Verifier; +use rsa::{Pkcs1v15Sign, RsaPublicKey}; +use sha2::{Digest, Sha256}; + +use crate::error::LicenseError; + +const EMBEDDED_AES_SALT: &[u8] = b"craftlabs-license-salt-v1-2026-05"; +const AES_NONCE_SIZE: usize = 12; + +pub fn verify_rsa_signature( + public_key_pem: &str, + data: &[u8], + signature_b64: &str, +) -> Result<(), LicenseError> { + use rsa::pkcs8::DecodePublicKey; + + let pub_key = RsaPublicKey::from_public_key_pem(public_key_pem) + .map_err(|_| LicenseError::InvalidSignature)?; + + let sig_bytes = URL_SAFE_NO_PAD + .decode(signature_b64) + .map_err(|_| LicenseError::InvalidFormat("signature base64"))?; + + let mut hasher = Sha256::new(); + hasher.update(data); + let digest = hasher.finalize(); + + pub_key + .verify(Pkcs1v15Sign::new::(), &digest, sig_bytes.as_slice()) + .map_err(|_| LicenseError::SignatureMismatch) +} + +pub fn derive_aes_key(license_id: &str) -> [u8; 32] { + let hk = Hkdf::::new(None, EMBEDDED_AES_SALT); + let mut okm = [0u8; 32]; + hk.expand(license_id.as_bytes(), &mut okm) + .expect("HKDF expand for 32 bytes"); + okm +} + +pub fn aes_gcm_decrypt(key: &[u8; 32], encrypted_data: &[u8]) -> Result, LicenseError> { + if encrypted_data.len() < AES_NONCE_SIZE + 16 { + return Err(LicenseError::DecryptionFailed); + } + + let (nonce_bytes, ciphertext) = encrypted_data.split_at(AES_NONCE_SIZE); + let nonce = Nonce::from_slice(nonce_bytes); + let cipher = + Aes256Gcm::new_from_slice(key).map_err(|_| LicenseError::CryptoError)?; + cipher + .decrypt(nonce, ciphertext) + .map_err(|_| LicenseError::DecryptionFailed) +} + +pub fn aes_gcm_encrypt(key: &[u8; 32], plaintext: &[u8]) -> Vec { + use aes_gcm::aead::rand_core::RngCore; + let mut nonce_bytes = [0u8; AES_NONCE_SIZE]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let cipher = Aes256Gcm::new_from_slice(key).expect("AES key valid"); + let mut result = nonce_bytes.to_vec(); + result.extend(cipher.encrypt(nonce, plaintext).expect("encrypt ok")); + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_derive_aes_key_deterministic() { + let k1 = derive_aes_key("01JQABCDEFFGHIJKLMNOPQRSTUV"); + let k2 = derive_aes_key("01JQABCDEFFGHIJKLMNOPQRSTUV"); + assert_eq!(k1, k2); + } + + #[test] + fn test_derive_aes_key_different_ids() { + let k1 = derive_aes_key("id-001"); + let k2 = derive_aes_key("id-002"); + assert_ne!(k1, k2); + } + + #[test] + fn test_aes_encrypt_decrypt_roundtrip() { + let key = derive_aes_key("roundtrip-test"); + let plaintext = b"hello selfhosted licensing!"; + let encrypted = aes_gcm_encrypt(&key, plaintext); + let decrypted = aes_gcm_decrypt(&key, &encrypted).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn test_aes_decrypt_wrong_key_fails() { + let key1 = derive_aes_key("id-1"); + let key2 = derive_aes_key("id-2"); + let plaintext = b"secret data"; + let encrypted = aes_gcm_encrypt(&key1, plaintext); + let result = aes_gcm_decrypt(&key2, &encrypted); + assert!(result.is_err()); + } + + #[test] + fn test_aes_decrypt_tampered_data_fails() { + let key = derive_aes_key("tamper-test"); + let plaintext = b"original"; + let mut encrypted = aes_gcm_encrypt(&key, plaintext); + if encrypted.len() > 20 { + encrypted[20] ^= 0xFF; + } + let result = aes_gcm_decrypt(&key, &encrypted); + assert!(result.is_err()); + } + + #[test] + fn test_rsa_verify_with_generated_keypair() { + use rsa::pkcs8::EncodePublicKey; + use rsa::signature::Signer; + use rsa::RsaPrivateKey; + use rand::rngs::OsRng; + + let mut rng = OsRng; + let priv_key = RsaPrivateKey::new(&mut rng, 2048).unwrap(); + let pub_key = RsaPublicKey::from(&priv_key); + let pub_pem = pub_key.to_public_key_pem().unwrap(); + + let data = b"test data to sign"; + let mut hasher = Sha256::new(); + hasher.update(data); + let digest = hasher.finalize(); + let sig = priv_key + .sign(Pkcs1v15Sign::new::(), &digest) + .unwrap(); + let sig_b64 = URL_SAFE_NO_PAD.encode(sig.as_bytes()); + + assert!(verify_rsa_signature(&pub_pem, data, &sig_b64).is_ok()); + assert!(verify_rsa_signature(&pub_pem, b"tampered", &sig_b64).is_err()); + } +} diff --git a/native/craft-core/src/lib.rs b/native/craft-core/src/lib.rs index 5bf352e..c2ec080 100644 --- a/native/craft-core/src/lib.rs +++ b/native/craft-core/src/lib.rs @@ -12,6 +12,7 @@ mod license; mod security; mod error; mod session; +pub mod crypto; pub struct CraftContext { dummy: i32,