Skip to main content

licenz_core/sneakernet/
request.rs

1//! Activation request file format
2//!
3//! The `.req` file contains all information needed for the licensing server
4//! to generate a license for an air-gapped machine.
5
6use crate::anti_tamper::HardwareFingerprint;
7use crate::error::{LicenseError, Result};
8use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use sha2::{Digest, Sha256};
12use std::collections::BTreeMap;
13use std::io::Write;
14use std::path::Path;
15use uuid::Uuid;
16
17use super::{
18    detect_format, SneakernetFormat, MAX_SNEAKERNET_JSON_PAYLOAD, REQUEST_MAGIC,
19    REQUEST_TEXT_PREFIX, REQUEST_TEXT_SUFFIX, REQUEST_VERSION,
20};
21
22/// An activation request generated on an air-gapped machine
23///
24/// This struct contains all the information a licensing server needs
25/// to generate a license that will work on the requesting machine.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ActivationRequest {
28    /// Unique identifier for this request (UUID v4)
29    pub request_id: Uuid,
30
31    /// Hardware fingerprint of the requesting machine
32    pub fingerprint: HardwareFingerprint,
33
34    /// Product identifier (which product is being activated)
35    pub product_id: String,
36
37    /// Requested features (optional - server may grant subset)
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub requested_features: Vec<String>,
40
41    /// When this request was created
42    pub timestamp: DateTime<Utc>,
43
44    /// Request format version (for future compatibility)
45    pub version: u8,
46
47    /// Additional metadata (customer info, notes, etc.)
48    ///
49    /// Uses `BTreeMap` for deterministic iteration order, which is critical for
50    /// consistent checksum computation.
51    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
52    pub metadata: BTreeMap<String, String>,
53
54    /// Checksum of the request data (for integrity verification)
55    /// This is computed from the serialized request content
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub checksum: Option<String>,
58}
59
60impl ActivationRequest {
61    /// Create a new activation request builder
62    pub fn builder() -> ActivationRequestBuilder {
63        ActivationRequestBuilder::new()
64    }
65
66    /// Load an activation request from a file (auto-detects format)
67    pub fn load(path: &Path) -> Result<Self> {
68        let data = std::fs::read(path)?;
69        Self::from_bytes(&data)
70    }
71
72    /// Parse an activation request from bytes (auto-detects format)
73    pub fn from_bytes(data: &[u8]) -> Result<Self> {
74        match detect_format(data) {
75            Some(SneakernetFormat::Binary) => Self::from_binary(data),
76            Some(SneakernetFormat::Text) => {
77                let text = std::str::from_utf8(data)
78                    .map_err(|e| LicenseError::InvalidLicenseFormat(e.to_string()))?;
79                Self::from_base64(text)
80            }
81            None => Err(LicenseError::InvalidLicenseFormat(
82                "Unknown activation request format".to_string(),
83            )),
84        }
85    }
86
87    /// Parse from binary format
88    pub fn from_binary(data: &[u8]) -> Result<Self> {
89        if data.len() < 9 {
90            return Err(LicenseError::InvalidLicenseFormat(
91                "Activation request too short".to_string(),
92            ));
93        }
94
95        // Verify magic header
96        if &data[0..4] != REQUEST_MAGIC {
97            return Err(LicenseError::InvalidLicenseFormat(
98                "Invalid activation request magic header".to_string(),
99            ));
100        }
101
102        // Check version
103        let version = data[4];
104        if version > REQUEST_VERSION {
105            return Err(LicenseError::InvalidLicenseFormat(format!(
106                "Unsupported activation request version: {} (max supported: {})",
107                version, REQUEST_VERSION
108            )));
109        }
110
111        // Read payload length
112        let len = u32::from_le_bytes([data[5], data[6], data[7], data[8]]) as usize;
113
114        if len > MAX_SNEAKERNET_JSON_PAYLOAD {
115            return Err(LicenseError::InvalidLicenseFormat(format!(
116                "Activation request payload exceeds maximum of {} bytes",
117                MAX_SNEAKERNET_JSON_PAYLOAD
118            )));
119        }
120
121        if data.len() < 9 + len {
122            return Err(LicenseError::InvalidLicenseFormat(
123                "Activation request data truncated".to_string(),
124            ));
125        }
126
127        // Deserialize the request
128        let request: Self = serde_json::from_slice(&data[9..9 + len])
129            .map_err(|e| LicenseError::InvalidLicenseFormat(e.to_string()))?;
130
131        // Verify checksum if present
132        if let Some(ref stored_checksum) = request.checksum {
133            let computed = request.compute_checksum();
134            if &computed != stored_checksum {
135                return Err(LicenseError::InvalidLicenseFormat(
136                    "Activation request checksum mismatch - data may be corrupted".to_string(),
137                ));
138            }
139        }
140
141        Ok(request)
142    }
143
144    /// Parse from base64 text format
145    pub fn from_base64(text: &str) -> Result<Self> {
146        let trimmed = text.trim();
147
148        // Strip prefix/suffix if present
149        let base64_content = if trimmed.starts_with(REQUEST_TEXT_PREFIX) {
150            trimmed
151                .strip_prefix(REQUEST_TEXT_PREFIX)
152                .and_then(|s| s.strip_suffix(REQUEST_TEXT_SUFFIX))
153                .map(|s| s.trim())
154                .ok_or_else(|| {
155                    LicenseError::InvalidLicenseFormat(
156                        "Malformed activation request text format".to_string(),
157                    )
158                })?
159        } else {
160            trimmed
161        };
162
163        // Remove any whitespace/newlines from base64 content
164        let clean_base64: String = base64_content
165            .chars()
166            .filter(|c| !c.is_whitespace())
167            .collect();
168
169        // Decode base64
170        let binary = BASE64
171            .decode(&clean_base64)
172            .map_err(|e| LicenseError::InvalidLicenseFormat(format!("Invalid base64: {}", e)))?;
173
174        // Parse as binary
175        Self::from_binary(&binary)
176    }
177
178    /// Export to binary format
179    pub fn to_binary(&self) -> Result<Vec<u8>> {
180        let mut output = Vec::new();
181
182        // Write magic header
183        output.write_all(REQUEST_MAGIC)?;
184
185        // Write version
186        output.write_all(&[REQUEST_VERSION])?;
187
188        // Serialize the request
189        let encoded = serde_json::to_vec(self)
190            .map_err(|e| LicenseError::SerializationError(e.to_string()))?;
191
192        // Write length as u32 little-endian
193        let len = encoded.len() as u32;
194        output.write_all(&len.to_le_bytes())?;
195
196        // Write the encoded request
197        output.write_all(&encoded)?;
198
199        Ok(output)
200    }
201
202    /// Export to base64 text format (suitable for email/copy-paste)
203    pub fn to_base64(&self) -> Result<String> {
204        let binary = self.to_binary()?;
205        let base64_content = BASE64.encode(&binary);
206
207        // Format with line wrapping (64 chars per line)
208        let wrapped: Vec<&str> = base64_content
209            .as_bytes()
210            .chunks(64)
211            .map(|chunk| std::str::from_utf8(chunk).unwrap())
212            .collect();
213
214        Ok(format!(
215            "{}\n{}\n{}",
216            REQUEST_TEXT_PREFIX,
217            wrapped.join("\n"),
218            REQUEST_TEXT_SUFFIX
219        ))
220    }
221
222    /// Save to a binary file
223    pub fn save_binary(&self, path: &Path) -> Result<()> {
224        let binary = self.to_binary()?;
225        std::fs::write(path, binary)?;
226        Ok(())
227    }
228
229    /// Save to a text file (base64 format)
230    pub fn save_text(&self, path: &Path) -> Result<()> {
231        let text = self.to_base64()?;
232        std::fs::write(path, text)?;
233        Ok(())
234    }
235
236    /// Compute checksum of request content (excluding the checksum field itself)
237    fn compute_checksum(&self) -> String {
238        let mut hasher = Sha256::new();
239
240        // Hash the key fields
241        hasher.update(self.request_id.as_bytes());
242        hasher.update(self.product_id.as_bytes());
243        hasher.update(self.fingerprint.combined_hash.as_bytes());
244        hasher.update(self.timestamp.to_rfc3339().as_bytes());
245        hasher.update([self.version]);
246
247        for feature in &self.requested_features {
248            hasher.update(feature.as_bytes());
249        }
250
251        for (key, value) in &self.metadata {
252            hasher.update(key.as_bytes());
253            hasher.update(value.as_bytes());
254        }
255
256        hex::encode(hasher.finalize())
257    }
258
259    /// Verify the request's integrity
260    pub fn verify_integrity(&self) -> bool {
261        match &self.checksum {
262            Some(stored) => &self.compute_checksum() == stored,
263            None => true, // No checksum to verify
264        }
265    }
266}
267
268/// Builder for creating activation requests
269#[derive(Default)]
270pub struct ActivationRequestBuilder {
271    fingerprint: Option<HardwareFingerprint>,
272    product_id: Option<String>,
273    requested_features: Vec<String>,
274    metadata: BTreeMap<String, String>,
275}
276
277impl ActivationRequestBuilder {
278    /// Create a new builder
279    pub fn new() -> Self {
280        Self::default()
281    }
282
283    /// Set the hardware fingerprint
284    pub fn fingerprint(mut self, fingerprint: HardwareFingerprint) -> Self {
285        self.fingerprint = Some(fingerprint);
286        self
287    }
288
289    /// Set the hardware fingerprint from the current machine
290    pub fn fingerprint_current(mut self) -> Self {
291        self.fingerprint = Some(HardwareFingerprint::generate());
292        self
293    }
294
295    /// Set the product ID
296    pub fn product_id(mut self, product_id: impl Into<String>) -> Self {
297        self.product_id = Some(product_id.into());
298        self
299    }
300
301    /// Add a requested feature
302    pub fn feature(mut self, feature: impl Into<String>) -> Self {
303        self.requested_features.push(feature.into());
304        self
305    }
306
307    /// Add multiple requested features
308    pub fn features(mut self, features: impl IntoIterator<Item = impl Into<String>>) -> Self {
309        self.requested_features
310            .extend(features.into_iter().map(|f| f.into()));
311        self
312    }
313
314    /// Add metadata
315    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
316        self.metadata.insert(key.into(), value.into());
317        self
318    }
319
320    /// Add customer name metadata
321    pub fn customer_name(self, name: impl Into<String>) -> Self {
322        self.metadata("customer_name", name)
323    }
324
325    /// Add customer email metadata
326    pub fn customer_email(self, email: impl Into<String>) -> Self {
327        self.metadata("customer_email", email)
328    }
329
330    /// Add order/purchase reference metadata
331    pub fn order_reference(self, reference: impl Into<String>) -> Self {
332        self.metadata("order_reference", reference)
333    }
334
335    /// Build the activation request
336    pub fn build(self) -> Result<ActivationRequest> {
337        let fingerprint = self.fingerprint.unwrap_or_default();
338        let product_id = self
339            .product_id
340            .ok_or_else(|| LicenseError::MissingField("product_id".into()))?;
341
342        let mut request = ActivationRequest {
343            request_id: Uuid::new_v4(),
344            fingerprint,
345            product_id,
346            requested_features: self.requested_features,
347            timestamp: Utc::now(),
348            version: REQUEST_VERSION,
349            metadata: self.metadata,
350            checksum: None,
351        };
352
353        // Compute and set checksum
354        request.checksum = Some(request.compute_checksum());
355
356        Ok(request)
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn create_test_fingerprint() -> HardwareFingerprint {
365        HardwareFingerprint {
366            mac_hashes: vec!["abc123".to_string()],
367            disk_hashes: vec!["def456".to_string()],
368            hostname_hash: Some("host789".to_string()),
369            machine_guid_hash: Some("guid012".to_string()),
370            combined_hash: "combined345".to_string(),
371        }
372    }
373
374    #[test]
375    fn test_request_builder() {
376        let request = ActivationRequest::builder()
377            .product_id("TEST-PRODUCT")
378            .fingerprint(create_test_fingerprint())
379            .feature("basic")
380            .feature("premium")
381            .customer_name("John Doe")
382            .customer_email("john@example.com")
383            .build()
384            .unwrap();
385
386        assert_eq!(request.product_id, "TEST-PRODUCT");
387        assert_eq!(request.requested_features.len(), 2);
388        assert!(request.requested_features.contains(&"basic".to_string()));
389        assert!(request.requested_features.contains(&"premium".to_string()));
390        assert_eq!(
391            request.metadata.get("customer_name"),
392            Some(&"John Doe".to_string())
393        );
394        assert!(request.checksum.is_some());
395    }
396
397    #[test]
398    fn test_request_builder_missing_product_id() {
399        let result = ActivationRequest::builder()
400            .fingerprint(create_test_fingerprint())
401            .build();
402
403        assert!(result.is_err());
404    }
405
406    #[test]
407    fn test_binary_serialization_roundtrip() {
408        let request = ActivationRequest::builder()
409            .product_id("MY-APP")
410            .fingerprint(create_test_fingerprint())
411            .feature("pro")
412            .build()
413            .unwrap();
414
415        let binary = request.to_binary().unwrap();
416
417        // Verify magic header
418        assert_eq!(&binary[0..4], REQUEST_MAGIC);
419        assert_eq!(binary[4], REQUEST_VERSION);
420
421        // Parse back
422        let parsed = ActivationRequest::from_binary(&binary).unwrap();
423
424        assert_eq!(parsed.request_id, request.request_id);
425        assert_eq!(parsed.product_id, request.product_id);
426        assert_eq!(parsed.requested_features, request.requested_features);
427        assert_eq!(parsed.checksum, request.checksum);
428    }
429
430    #[test]
431    fn test_base64_serialization_roundtrip() {
432        let request = ActivationRequest::builder()
433            .product_id("MY-APP")
434            .fingerprint(create_test_fingerprint())
435            .feature("enterprise")
436            .customer_name("Acme Corp")
437            .build()
438            .unwrap();
439
440        let text = request.to_base64().unwrap();
441
442        // Verify format
443        assert!(text.starts_with(REQUEST_TEXT_PREFIX));
444        assert!(text.ends_with(REQUEST_TEXT_SUFFIX));
445
446        // Parse back
447        let parsed = ActivationRequest::from_base64(&text).unwrap();
448
449        assert_eq!(parsed.request_id, request.request_id);
450        assert_eq!(parsed.product_id, request.product_id);
451        assert_eq!(parsed.requested_features, request.requested_features);
452        assert_eq!(
453            parsed.metadata.get("customer_name"),
454            Some(&"Acme Corp".to_string())
455        );
456    }
457
458    #[test]
459    fn test_auto_detect_format() {
460        let request = ActivationRequest::builder()
461            .product_id("AUTO-DETECT")
462            .fingerprint(create_test_fingerprint())
463            .build()
464            .unwrap();
465
466        // Test binary
467        let binary = request.to_binary().unwrap();
468        let parsed_binary = ActivationRequest::from_bytes(&binary).unwrap();
469        assert_eq!(parsed_binary.product_id, "AUTO-DETECT");
470
471        // Test text
472        let text = request.to_base64().unwrap();
473        let parsed_text = ActivationRequest::from_bytes(text.as_bytes()).unwrap();
474        assert_eq!(parsed_text.product_id, "AUTO-DETECT");
475    }
476
477    #[test]
478    fn test_integrity_verification() {
479        let request = ActivationRequest::builder()
480            .product_id("INTEGRITY-TEST")
481            .fingerprint(create_test_fingerprint())
482            .build()
483            .unwrap();
484
485        assert!(request.verify_integrity());
486    }
487
488    #[test]
489    fn test_invalid_magic_header() {
490        let mut bad_data = vec![b'B', b'A', b'D', b'!'];
491        bad_data.extend_from_slice(&[1, 0, 0, 0, 0]);
492
493        let result = ActivationRequest::from_binary(&bad_data);
494        assert!(result.is_err());
495    }
496
497    #[test]
498    fn test_truncated_data() {
499        let request = ActivationRequest::builder()
500            .product_id("TRUNCATE-TEST")
501            .fingerprint(create_test_fingerprint())
502            .build()
503            .unwrap();
504
505        let binary = request.to_binary().unwrap();
506
507        // Truncate the data
508        let truncated = &binary[0..20];
509
510        let result = ActivationRequest::from_binary(truncated);
511        assert!(result.is_err());
512    }
513}