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:
@@ -20,6 +20,11 @@ base64 = "0.22"
|
|||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
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]
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
windows-sys = { version = "0.52", features = ["Win32_System_Diagnostics_Debug"], optional = true }
|
windows-sys = { version = "0.52", features = ["Win32_System_Diagnostics_Debug"], optional = true }
|
||||||
|
|||||||
@@ -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 cache;
|
||||||
pub mod license;
|
pub mod license;
|
||||||
|
pub mod protocol;
|
||||||
|
pub mod activate;
|
||||||
|
pub mod heartbeat;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::{
|
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)
|
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> {
|
fn activate(&self, _ctx: &CraftContext, license_key: &str) -> Result<ActivateResponse, LicenseError> {
|
||||||
Err(LicenseError::Network("online activation not yet implemented".into()))
|
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> {
|
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> {
|
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 {
|
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