keygen_rs/
machine_file.rs

1use base64::{engine::general_purpose, Engine};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::{
7    certificate::{
8        validate_certificate_meta, Certificate, CertificateFileAttributes, CertificateFileMeta,
9    },
10    component::Component,
11    decryptor::Decryptor,
12    entitlement::Entitlement,
13    errors::Error,
14    group::Group,
15    license::License,
16    license_file::IncludedResources,
17    machine::{Machine, MachineAttributes},
18    verifier::Verifier,
19    KeygenResponseData,
20};
21
22#[derive(Debug, Serialize, Deserialize)]
23pub struct MachineFileDataset {
24    pub license: License,
25    pub machine: Machine,
26    pub issued: DateTime<Utc>,
27    pub expiry: DateTime<Utc>,
28    pub ttl: i32,
29    #[serde(default)]
30    pub included: Option<IncludedResources>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct MachineFile {
35    pub id: String,
36    pub certificate: String,
37    pub issued: DateTime<Utc>,
38    pub expiry: DateTime<Utc>,
39    pub ttl: i32,
40}
41
42impl From<CertificateFileAttributes> for MachineFile {
43    fn from(val: CertificateFileAttributes) -> Self {
44        MachineFile {
45            id: "".into(),
46            certificate: val.certificate,
47            issued: val.issued,
48            expiry: val.expiry,
49            ttl: val.ttl,
50        }
51    }
52}
53
54impl MachineFile {
55    pub(crate) fn from(data: KeygenResponseData<CertificateFileAttributes>) -> MachineFile {
56        MachineFile {
57            id: data.id,
58            ..data.attributes.into()
59        }
60    }
61
62    pub fn from_cert(key: &str, content: &str) -> Result<MachineFile, Error> {
63        let dataset = Self::_decrypt(key, content)?;
64        Ok(MachineFile {
65            id: dataset.machine.id.clone(),
66            certificate: content.to_string(),
67            issued: dataset.issued,
68            expiry: dataset.expiry,
69            ttl: dataset.ttl,
70        })
71    }
72
73    pub fn verify(&self) -> Result<(), Error> {
74        self.validate_ttl()?;
75
76        let config = crate::config::get_config()?;
77
78        if let Some(public_key) = &config.public_key {
79            let verifier = Verifier::new(public_key.clone());
80            verifier.verify_machine_file(self)
81        } else {
82            Err(Error::PublicKeyMissing)
83        }
84    }
85
86    pub fn validate_ttl(&self) -> Result<(), Error> {
87        let now = Utc::now();
88        if now > self.expiry {
89            let dataset = self.decrypt("").unwrap_or_else(|_| {
90                use std::collections::HashMap;
91                MachineFileDataset {
92                    license: License::from(crate::KeygenResponseData {
93                        id: "".to_string(),
94                        r#type: "licenses".to_string(),
95                        attributes: crate::license::LicenseAttributes {
96                            name: None,
97                            key: "".to_string(),
98                            expiry: None,
99                            status: Some("".to_string()),
100                            uses: Some(0),
101                            max_machines: None,
102                            max_cores: None,
103                            max_uses: None,
104                            max_processes: None,
105                            max_users: None,
106                            protected: None,
107                            suspended: None,
108                            permissions: None,
109                            metadata: HashMap::new(),
110                        },
111                        relationships: crate::KeygenRelationships::default(),
112                    }),
113                    machine: Machine::from(crate::KeygenResponseData {
114                        id: "".to_string(),
115                        r#type: "machines".to_string(),
116                        attributes: crate::machine::MachineAttributes {
117                            fingerprint: "".to_string(),
118                            name: None,
119                            platform: None,
120                            hostname: None,
121                            ip: None,
122                            cores: None,
123                            metadata: None,
124                            require_heartbeat: false,
125                            heartbeat_status: "".to_string(),
126                            heartbeat_duration: None,
127                            created: Utc::now(),
128                            updated: Utc::now(),
129                        },
130                        relationships: crate::KeygenRelationships::default(),
131                    }),
132                    issued: self.issued,
133                    expiry: self.expiry,
134                    ttl: self.ttl,
135                    included: None,
136                }
137            });
138            Err(Error::MachineFileExpired(Box::new(dataset)))
139        } else {
140            Ok(())
141        }
142    }
143
144    pub fn decrypt(&self, key: &str) -> Result<MachineFileDataset, Error> {
145        Self::_decrypt(key, &self.certificate)
146    }
147
148    pub fn certificate(&self) -> Result<Certificate, Error> {
149        Self::_certificate(self.certificate.clone())
150    }
151
152    fn _decrypt(key: &str, content: &str) -> Result<MachineFileDataset, Error> {
153        let cert = Self::_certificate(content.to_string())?;
154
155        match cert.alg.as_str() {
156            "aes-256-gcm+rsa-pss-sha256" | "aes-256-gcm+rsa-sha256" => {
157                return Err(Error::LicenseFileNotSupported(cert.alg.clone()));
158            }
159            "aes-256-gcm+ed25519" => {}
160            _ => return Err(Error::LicenseFileNotEncrypted),
161        }
162
163        let decryptor = Decryptor::new(key.to_string());
164        let data = decryptor.decrypt_certificate(&cert)?;
165        let dataset: Value =
166            serde_json::from_slice(&data).map_err(|e| Error::MachineFileInvalid(e.to_string()))?;
167
168        let meta: CertificateFileMeta = serde_json::from_value(dataset["meta"].clone())
169            .map_err(|e| Error::LicenseFileInvalid(e.to_string()))?;
170
171        // Parse included relationships
172        let included_array = dataset["included"]
173            .as_array()
174            .ok_or(Error::MachineFileInvalid(
175                "Included data is not an array".into(),
176            ))?;
177
178        // Find type = "licenses" element in dataset["included"] array
179        let license_data = included_array
180            .iter()
181            .find(|v| v["type"] == "licenses")
182            .ok_or(Error::MachineFileInvalid(
183                "No license data found in included data".into(),
184            ))?;
185        let license = License::from(serde_json::from_value(license_data.clone())?);
186
187        // Parse other included relationships if present
188        let included = if included_array.len() > 1 {
189            Some(IncludedResources::parse_from_json(&Value::Array(
190                included_array.clone(),
191            ))?)
192        } else {
193            None
194        };
195
196        let machine_data: KeygenResponseData<MachineAttributes> =
197            serde_json::from_value(dataset["data"].clone())
198                .map_err(|e| Error::MachineFileInvalid(e.to_string()))?;
199        let machine = Machine::from(machine_data);
200
201        let dataset = MachineFileDataset {
202            license,
203            machine,
204            issued: meta.issued,
205            expiry: meta.expiry,
206            ttl: meta.ttl,
207            included,
208        };
209
210        if let Err(err) = validate_certificate_meta(&meta) {
211            match err {
212                Error::CertificateFileExpired => Err(Error::MachineFileExpired(Box::new(dataset))),
213                _ => Err(err),
214            }
215        } else {
216            Ok(dataset)
217        }
218    }
219
220    fn _certificate(certificate: String) -> Result<Certificate, Error> {
221        let payload = certificate.trim();
222        let payload = payload
223            .strip_prefix("-----BEGIN MACHINE FILE-----")
224            .and_then(|s| s.strip_suffix("-----END MACHINE FILE-----"))
225            .ok_or(Error::MachineFileInvalid(
226                "Invalid machine file format".into(),
227            ))?
228            .trim()
229            .replace("\n", "");
230
231        let decoded = general_purpose::STANDARD
232            .decode(payload)
233            .map_err(|e| Error::MachineFileInvalid(e.to_string()))?;
234
235        let cert: Certificate = serde_json::from_slice(&decoded)
236            .map_err(|e| Error::MachineFileInvalid(e.to_string()))?;
237
238        Ok(cert)
239    }
240
241    /// Get entitlements from the machine file without making an API call
242    /// Requires the decryption key and the machine file to include entitlements
243    pub fn entitlements(&self, key: &str) -> Result<Vec<Entitlement>, Error> {
244        let dataset = self.decrypt(key)?;
245        Ok(dataset.offline_entitlements().unwrap_or(&vec![]).clone())
246    }
247
248    /// Get components from the machine file without making an API call
249    /// Requires the decryption key and the machine file to include components
250    pub fn components(&self, key: &str) -> Result<Vec<Component>, Error> {
251        let dataset = self.decrypt(key)?;
252        Ok(dataset.offline_components().unwrap_or(&vec![]).clone())
253    }
254
255    /// Get groups from the machine file without making an API call
256    /// Requires the decryption key and the machine file to include groups
257    pub fn groups(&self, key: &str) -> Result<Vec<Group>, Error> {
258        let dataset = self.decrypt(key)?;
259        Ok(dataset.offline_groups().unwrap_or(&vec![]).clone())
260    }
261}
262
263impl MachineFileDataset {
264    /// Get cached entitlements without making an API call
265    pub fn offline_entitlements(&self) -> Option<&Vec<Entitlement>> {
266        self.included.as_ref().map(|inc| &inc.entitlements)
267    }
268
269    /// Get cached components without making an API call
270    pub fn offline_components(&self) -> Option<&Vec<Component>> {
271        self.included.as_ref().map(|inc| &inc.components)
272    }
273
274    /// Get cached groups without making an API call
275    pub fn offline_groups(&self) -> Option<&Vec<Group>> {
276        self.included.as_ref().map(|inc| &inc.groups)
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283    use crate::machine::MachineCheckoutOpts;
284    use serde_json::json;
285
286    #[test]
287    fn test_machine_file_included_resources_parsing() {
288        // Test parsing of included relationships from JSON API format
289        let included_json = json!([
290            {
291                "type": "licenses",
292                "id": "lic1",
293                "attributes": {
294                    "name": "Test License",
295                    "key": "test-key",
296                    "expiry": null,
297                    "status": "active",
298                    "uses": 0,
299                    "maxMachines": 5,
300                    "maxCores": null,
301                    "maxUses": null,
302                    "maxProcesses": null,
303                    "maxUsers": null,
304                    "protected": false,
305                    "suspended": false,
306                    "permissions": null,
307                    "metadata": {}
308                },
309                "relationships": {
310                    "account": {"data": {"type": "accounts", "id": "acc1"}}
311                }
312            },
313            {
314                "type": "entitlements",
315                "id": "ent1",
316                "attributes": {
317                    "name": "Feature A",
318                    "code": "feature-a",
319                    "metadata": {},
320                    "created": "2023-01-01T00:00:00Z",
321                    "updated": "2023-01-01T00:00:00Z"
322                },
323                "relationships": {
324                    "account": {"data": {"type": "accounts", "id": "acc1"}}
325                }
326            },
327            {
328                "type": "components",
329                "id": "comp1",
330                "attributes": {
331                    "fingerprint": "component-fingerprint",
332                    "name": "CPU Component"
333                }
334            }
335        ]);
336
337        let result = IncludedResources::parse_from_json(&included_json);
338        assert!(result.is_ok());
339
340        let included = result.unwrap();
341        assert_eq!(included.entitlements.len(), 1);
342        assert_eq!(included.entitlements[0].code, "feature-a");
343        assert_eq!(included.entitlements[0].name, Some("Feature A".to_string()));
344
345        assert_eq!(included.components.len(), 1);
346        assert_eq!(included.components[0].id, "comp1");
347        assert_eq!(included.components[0].fingerprint, "component-fingerprint");
348        assert_eq!(included.components[0].name, "CPU Component");
349    }
350
351    #[test]
352    fn test_machine_checkout_opts_with_ttl() {
353        let opts = MachineCheckoutOpts::with_ttl(7200);
354
355        assert_eq!(opts.ttl, Some(7200));
356        assert!(opts.include.is_none());
357    }
358
359    #[test]
360    fn test_machine_checkout_opts_with_include() {
361        let include_vec = vec!["license.entitlements".to_string(), "components".to_string()];
362        let opts = MachineCheckoutOpts::with_include(include_vec);
363
364        assert!(opts.include.is_some());
365        let includes = opts.include.unwrap();
366        assert!(includes.contains(&"license.entitlements".to_string()));
367        assert!(includes.contains(&"components".to_string()));
368        assert_eq!(includes.len(), 2);
369        assert!(opts.ttl.is_none());
370    }
371
372    #[test]
373    fn test_machine_checkout_opts_new() {
374        let opts = MachineCheckoutOpts::new();
375
376        assert!(opts.ttl.is_none());
377        assert!(opts.include.is_none());
378    }
379}