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 let included_array = dataset["included"]
174 .as_array()
175 .ok_or(Error::MachineFileInvalid(
176 "Included data is not an array".into(),
177 ))?;
178
179 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 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 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 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 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 pub fn offline_entitlements(&self) -> Option<&Vec<Entitlement>> {
267 self.included.as_ref().map(|inc| &inc.entitlements)
268 }
269
270 pub fn offline_components(&self) -> Option<&Vec<Component>> {
272 self.included.as_ref().map(|inc| &inc.components)
273 }
274
275 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 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}