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