mirror of
https://github.com/hpd840321/craftlabs-authorization-sdk.git
synced 2026-06-09 10:00:30 +08:00
feat(rust): add online activation and heartbeat (HTTPS + HMAC signing) for selfhosted provider
This commit is contained in:
@@ -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, ×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())),
|
||||
}
|
||||
}
|
||||
@@ -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, ×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())),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user