feat(rust): add online activation and heartbeat (HTTPS + HMAC signing) for selfhosted provider

This commit is contained in:
2026-05-18 22:37:03 +08:00
parent 6f79bb97d9
commit ebb3da2ad6
5 changed files with 240 additions and 3 deletions
@@ -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<ActivateResponse, LicenseError> {
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, &timestamp, "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", &timestamp)
.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())),
}
}
@@ -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<HeartbeatResponse, LicenseError> {
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, &timestamp, "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", &timestamp)
.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())),
}
}
@@ -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<ActivateResponse, LicenseError> {
Err(LicenseError::Network("online activation not yet implemented".into()))
fn activate(&self, _ctx: &CraftContext, license_key: &str) -> Result<ActivateResponse, LicenseError> {
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<LicenseStatus, LicenseError> {
@@ -168,7 +174,12 @@ impl Provider for SelfHostedProvider {
}
fn heartbeat(&self, _ctx: &CraftContext) -> Result<HeartbeatResponse, LicenseError> {
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 {
@@ -0,0 +1,90 @@
use hmac::{Hmac, Mac};
use sha2::Sha256;
use serde::{Deserialize, Serialize};
type HmacSha256 = Hmac<Sha256>;
#[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<FingerprintLayerRequest>,
}
#[derive(Serialize)]
pub struct FingerprintLayerRequest {
pub source: String,
pub value: Option<String>,
}
#[derive(Deserialize, Debug)]
pub struct ActivateResponseBody {
pub status: String,
pub device_id: String,
pub license_payload: Option<String>,
#[serde(default)]
pub error: Option<String>,
}
#[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<String>,
#[serde(default)]
pub update_available: Option<bool>,
#[serde(default)]
pub new_license_payload: Option<String>,
}
#[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<std::collections::HashMap<String, bool>>,
#[serde(default)]
pub not_after: Option<String>,
}
#[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)
}