From ebb3da2ad6ab91b03d693a14b48ba88a274b8718 Mon Sep 17 00:00:00 2001 From: huangping Date: Mon, 18 May 2026 22:37:03 +0800 Subject: [PATCH] feat(rust): add online activation and heartbeat (HTTPS + HMAC signing) for selfhosted provider --- native/craft-core/Cargo.toml | 5 ++ .../src/provider_selfhosted/activate.rs | 72 +++++++++++++++ .../src/provider_selfhosted/heartbeat.rs | 59 ++++++++++++ .../craft-core/src/provider_selfhosted/mod.rs | 17 +++- .../src/provider_selfhosted/protocol.rs | 90 +++++++++++++++++++ 5 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 native/craft-core/src/provider_selfhosted/activate.rs create mode 100644 native/craft-core/src/provider_selfhosted/heartbeat.rs create mode 100644 native/craft-core/src/provider_selfhosted/protocol.rs diff --git a/native/craft-core/Cargo.toml b/native/craft-core/Cargo.toml index 20c9640..542cccb 100644 --- a/native/craft-core/Cargo.toml +++ b/native/craft-core/Cargo.toml @@ -20,6 +20,11 @@ base64 = "0.22" serde = { version = "1", features = ["derive"] } serde_json = "1" chrono = { version = "0.4", features = ["serde"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } +tokio = { version = "1", features = ["rt", "macros"] } +hex = "0.4" +hmac = "0.12" +rand = "0.8" [target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.52", features = ["Win32_System_Diagnostics_Debug"], optional = true } diff --git a/native/craft-core/src/provider_selfhosted/activate.rs b/native/craft-core/src/provider_selfhosted/activate.rs new file mode 100644 index 0000000..69856a3 --- /dev/null +++ b/native/craft-core/src/provider_selfhosted/activate.rs @@ -0,0 +1,72 @@ +use crate::trait_provider::ActivateResponse; +use crate::error::LicenseError; +use crate::device::DeviceFingerprint; +use super::{protocol, SelfHostedConfig}; + +pub async fn online_activate( + config: &SelfHostedConfig, + fp: &DeviceFingerprint, + license_key: &str, +) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| LicenseError::Network(e.to_string()))?; + + let req = protocol::ActivateRequest { + license_key: license_key.to_string(), + device_fingerprint: protocol::DeviceFingerprintRequest { + composite_hash: fp.composite_hash.clone(), + stability_score: fp.stability_score, + layers: fp.layers.iter().map(|l| protocol::FingerprintLayerRequest { + source: l.source.to_string(), + value: l.value.clone(), + }).collect(), + }, + }; + + let body = serde_json::to_string(&req).unwrap(); + let nonce = protocol::generate_nonce(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() + .to_string(); + let path = "/license/v1/activate"; + let sig = protocol::build_hmac_signature( + &config.tenant_key, &nonce, ×tamp, "POST", path, &body, + ); + + let url = format!("{}{}", config.base_url, path); + let resp = client + .post(&url) + .header("Authorization", format!("Bearer {}", config.tenant_key)) + .header("X-Craft-Nonce", &nonce) + .header("X-Craft-Timestamp", ×tamp) + .header("X-Craft-Signature", &sig) + .header("Content-Type", "application/json") + .body(body) + .send() + .await + .map_err(|e| LicenseError::Network(e.to_string()))?; + + match resp.status() { + reqwest::StatusCode::OK => { + let body: protocol::ActivateResponseBody = resp.json().await + .map_err(|e| LicenseError::Network(e.to_string()))?; + if body.status == "activated" { + Ok(ActivateResponse { + success: true, + device_id: body.device_id, + license_payload: body.license_payload.unwrap_or_default().into_bytes(), + }) + } else { + Err(LicenseError::InvalidLicense) + } + } + reqwest::StatusCode::CONFLICT => Err(LicenseError::DeviceLimitReached), + reqwest::StatusCode::FORBIDDEN => Err(LicenseError::LicenseRevoked), + reqwest::StatusCode::UNPROCESSABLE_ENTITY => Err(LicenseError::InvalidLicense), + s => Err(LicenseError::UnknownStatus(s.as_u16())), + } +} diff --git a/native/craft-core/src/provider_selfhosted/heartbeat.rs b/native/craft-core/src/provider_selfhosted/heartbeat.rs new file mode 100644 index 0000000..f0ed7bf --- /dev/null +++ b/native/craft-core/src/provider_selfhosted/heartbeat.rs @@ -0,0 +1,59 @@ +use crate::trait_provider::HeartbeatResponse; +use crate::error::LicenseError; +use super::{protocol, SelfHostedConfig}; + +pub async fn online_heartbeat( + config: &SelfHostedConfig, + device_hash: &str, + license_key: &str, +) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(15)) + .build() + .map_err(|e| LicenseError::Network(e.to_string()))?; + + let req = protocol::HeartbeatRequest { + license_key: license_key.to_string(), + device_hash: device_hash.to_string(), + local_time: chrono::Utc::now().to_rfc3339(), + }; + + let body = serde_json::to_string(&req).unwrap(); + let nonce = protocol::generate_nonce(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs().to_string(); + let sig = protocol::build_hmac_signature( + &config.tenant_key, &nonce, ×tamp, "POST", "/license/v1/heartbeat", &body, + ); + + let url = format!("{}/license/v1/heartbeat", config.base_url); + let resp = client.post(&url) + .header("Authorization", format!("Bearer {}", config.tenant_key)) + .header("X-Craft-Nonce", &nonce) + .header("X-Craft-Timestamp", ×tamp) + .header("X-Craft-Signature", &sig) + .body(body) + .send().await + .map_err(|e| LicenseError::Network(e.to_string()))?; + + match resp.status() { + reqwest::StatusCode::OK => { + let body: protocol::HeartbeatResponseBody = resp.json().await + .map_err(|e| LicenseError::Network(e.to_string()))?; + if body.status == "ok" { + Ok(HeartbeatResponse { + valid: true, + lease_until: body.lease_renewed_until + .and_then(|s| chrono::DateTime::parse_from_rfc3339(&s).ok()) + .map(|d| d.timestamp()), + update_available: body.update_available.unwrap_or(false), + new_license_payload: body.new_license_payload.map(|s| s.into_bytes()), + }) + } else { + Err(LicenseError::LicenseRevoked) + } + } + reqwest::StatusCode::GONE => Err(LicenseError::LicenseRevoked), + s => Err(LicenseError::UnknownStatus(s.as_u16())), + } +} diff --git a/native/craft-core/src/provider_selfhosted/mod.rs b/native/craft-core/src/provider_selfhosted/mod.rs index cc5747a..296224e 100644 --- a/native/craft-core/src/provider_selfhosted/mod.rs +++ b/native/craft-core/src/provider_selfhosted/mod.rs @@ -1,5 +1,8 @@ pub mod cache; pub mod license; +pub mod protocol; +pub mod activate; +pub mod heartbeat; use std::collections::HashMap; use crate::{ @@ -150,8 +153,11 @@ impl Provider for SelfHostedProvider { self.initialize(base_url, tenant_key, offline_grace_days, heartbeat_interval_hours, public_key_pem) } - fn activate(&self, _ctx: &CraftContext, _license_key: &str) -> Result { - Err(LicenseError::Network("online activation not yet implemented".into())) + fn activate(&self, _ctx: &CraftContext, license_key: &str) -> Result { + let cfg = self.config.as_ref().ok_or(LicenseError::NotInitialized)?; + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| LicenseError::Network("no tokio runtime".into()))?; + rt.block_on(activate::online_activate(cfg, &self.device_fp, license_key)) } fn check_license(&self, _ctx: &CraftContext) -> Result { @@ -168,7 +174,12 @@ impl Provider for SelfHostedProvider { } fn heartbeat(&self, _ctx: &CraftContext) -> Result { - Err(LicenseError::Network("heartbeat not yet implemented".into())) + let cfg = self.config.as_ref().ok_or(LicenseError::NotInitialized)?; + let fph = &self.device_fp.composite_hash; + let lk = self.cache.license.as_ref().map(|c| c.license_id.as_str()).unwrap_or(""); + let rt = tokio::runtime::Handle::try_current() + .map_err(|_| LicenseError::Network("no tokio runtime".into()))?; + rt.block_on(heartbeat::online_heartbeat(cfg, fph, lk)) } fn has_feature(&self, _ctx: &CraftContext, name: &str) -> bool { diff --git a/native/craft-core/src/provider_selfhosted/protocol.rs b/native/craft-core/src/provider_selfhosted/protocol.rs new file mode 100644 index 0000000..84799f9 --- /dev/null +++ b/native/craft-core/src/provider_selfhosted/protocol.rs @@ -0,0 +1,90 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use serde::{Deserialize, Serialize}; + +type HmacSha256 = Hmac; + +#[derive(Serialize)] +pub struct ActivateRequest { + pub license_key: String, + pub device_fingerprint: DeviceFingerprintRequest, +} + +#[derive(Serialize)] +pub struct DeviceFingerprintRequest { + pub composite_hash: String, + pub stability_score: u8, + pub layers: Vec, +} + +#[derive(Serialize)] +pub struct FingerprintLayerRequest { + pub source: String, + pub value: Option, +} + +#[derive(Deserialize, Debug)] +pub struct ActivateResponseBody { + pub status: String, + pub device_id: String, + pub license_payload: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Serialize)] +pub struct HeartbeatRequest { + pub license_key: String, + pub device_hash: String, + pub local_time: String, +} + +#[derive(Deserialize, Debug)] +pub struct HeartbeatResponseBody { + pub status: String, + #[serde(default)] + pub lease_renewed_until: Option, + #[serde(default)] + pub update_available: Option, + #[serde(default)] + pub new_license_payload: Option, +} + +#[derive(Serialize)] +pub struct CheckRequest { + pub license_key: String, + pub device_hash: String, +} + +#[derive(Deserialize, Debug)] +pub struct CheckResponseBody { + pub status: String, + #[serde(default)] + pub features: Option>, + #[serde(default)] + pub not_after: Option, +} + +#[derive(Serialize)] +pub struct ReleaseRequest { + pub license_key: String, + pub device_hash: String, +} + +pub fn build_hmac_signature( + tenant_key: &str, nonce: &str, timestamp: &str, + method: &str, path: &str, body: &str, +) -> String { + let message = format!("{}|{}|{}|{}|{}", nonce, timestamp, method, path, body); + let mut mac = HmacSha256::new_from_slice(tenant_key.as_bytes()) + .expect("HMAC key creation"); + mac.update(message.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +pub fn generate_nonce() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let bytes: [u8; 16] = rng.gen(); + hex::encode(bytes) +}