Skip to main content

rite_runtime/actions/
types.rs

1//! Core types for action handling.
2
3use base64ct::{Base64, Encoding};
4
5use rite_sdk::{KeyAlgorithm, KeyId, WrapAlgorithm};
6
7/// Output format for public keys.
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum KeyFormat {
10    /// DER binary format.
11    Der,
12    /// PEM text format.
13    Pem,
14}
15
16/// Runtime representation of an artifact.
17#[derive(Debug)]
18pub enum ArtifactValue {
19    /// Cryptographic key managed by a backend (software, HSM, `YubiKey`).
20    /// The private key never leaves the backend - only references are stored.
21    BackendKey {
22        /// Name of the backend that owns this key (e.g., "software", "yubikey1").
23        backend_name: String,
24        /// Backend-specific key identifier (opaque, e.g., UUID or "`slot_9c`").
25        key_id: KeyId,
26        /// Algorithm for type checking and evidence.
27        algorithm: KeyAlgorithm,
28        /// Public key in SPKI DER format (None for non-exportable HSM keys).
29        public_key: Option<Vec<u8>>,
30    },
31    /// Wrapped key (CMS `EnvelopedData`, AES Key Wrap, or RSA-OAEP).
32    WrappedKey {
33        /// Wrapped key bytes (format depends on algorithm).
34        data: Vec<u8>,
35        /// Wrapping algorithm used.
36        algorithm: WrapAlgorithm,
37    },
38    /// Exported public key.
39    PublicKey {
40        /// Public key bytes (SPKI DER format).
41        key_data: Vec<u8>,
42        /// Output format.
43        format: KeyFormat,
44    },
45    /// Binary content from files or inline data (documents, crypto materials).
46    /// Used for hashing, cryptographic operations, and verification.
47    Bytes(Vec<u8>),
48
49    /// Display text for physical item references (USB drives, tamper bags, etc.).
50    /// Used in messages and prompts when referencing physical objects.
51    Text(String),
52
53    /// X.509 certificate produced by `issue_certificate` action.
54    /// Stored as DER bytes; displayed and serialized as PEM.
55    Certificate {
56        /// Certificate bytes in DER format.
57        der: Vec<u8>,
58    },
59}
60
61impl std::fmt::Display for ArtifactValue {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        match self {
64            // Real cryptographic artifacts
65            ArtifactValue::BackendKey {
66                backend_name,
67                key_id,
68                algorithm,
69                public_key,
70                ..
71            } => {
72                // Output backend key metadata and public key if available
73                if let Some(pub_key) = public_key {
74                    write!(
75                        f,
76                        "BackendKey(backend={backend_name}, key_id={}, algorithm={algorithm:?})\n{}",
77                        key_id.as_str(),
78                        encode_pem("PUBLIC KEY", pub_key)
79                    )
80                } else {
81                    write!(
82                        f,
83                        "BackendKey(backend={backend_name}, key_id={}, algorithm={algorithm:?}, public_key=not_exportable)",
84                        key_id.as_str()
85                    )
86                }
87            }
88            ArtifactValue::WrappedKey { data, algorithm } => {
89                let label = match algorithm {
90                    WrapAlgorithm::CmsRsaCbc | WrapAlgorithm::CmsRsaGcm => "CMS",
91                    _ => "WRAPPED KEY",
92                };
93                let pem = encode_pem(label, data);
94                write!(f, "{pem}")
95            }
96            ArtifactValue::PublicKey {
97                key_data,
98                format: KeyFormat::Pem,
99            } => {
100                let pem = encode_pem("PUBLIC KEY", key_data);
101                write!(f, "{pem}")
102            }
103            ArtifactValue::PublicKey {
104                key_data,
105                format: KeyFormat::Der,
106            } => {
107                // DER is binary, output as base64
108                let encoded = base64_encode(key_data);
109                write!(f, "{encoded}")
110            }
111            // Materials
112            ArtifactValue::Bytes(bytes) => {
113                let len = bytes.len();
114                write!(f, "Bytes({len} bytes)")
115            }
116            ArtifactValue::Text(text) => {
117                write!(f, "Text({text})")
118            }
119
120            ArtifactValue::Certificate { der } => {
121                let pem = encode_pem("CERTIFICATE", der);
122                write!(f, "{pem}")
123            }
124        }
125    }
126}
127
128/// Encode bytes as base64 (standard alphabet with padding).
129/// Uses constant-time encoding from `RustCrypto` `base64ct`.
130pub(crate) fn base64_encode(data: &[u8]) -> String {
131    Base64::encode_string(data)
132}
133
134/// Encode DER bytes as PEM with given label.
135pub(crate) fn encode_pem(label: &str, der_bytes: &[u8]) -> String {
136    let b64 = base64_encode(der_bytes);
137    // Split into 64-character lines (PEM standard)
138    let lines: Vec<&str> = b64
139        .as_bytes()
140        .chunks(64)
141        .map(|chunk| std::str::from_utf8(chunk).unwrap_or(""))
142        .collect();
143
144    let body = lines.join("\n");
145    format!("-----BEGIN {label}-----\n{body}\n-----END {label}-----")
146}
147
148/// A serialized artifact ready for output.
149///
150/// Contains both the serialized bytes and metadata about the content.
151/// Produced by calling `artifact.serialize(format)`.
152///
153/// Metadata includes:
154/// - MIME type (see <https://pki-tutorial.readthedocs.io/en/latest/mime.html>)
155/// - File extension appropriate for the format
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct SerializedArtifact {
158    /// Serialized content bytes.
159    pub bytes: Vec<u8>,
160    /// MIME type for the artifact in the specified format.
161    pub mime_type: Option<String>,
162    /// File extension appropriate for the format (without leading dot).
163    pub extension: &'static str,
164}
165
166impl ArtifactValue {
167    /// Serialize artifact to bytes with metadata.
168    ///
169    /// Returns a `SerializedArtifact` containing both the serialized bytes
170    /// and metadata (MIME type, file extension) appropriate for the format.
171    ///
172    /// Applies sensible defaults if format is None, then validates strictly.
173    ///
174    /// Supported formats by artifact type:
175    /// - `WrappedKey`: "der" (default), "pem"
176    /// - `PublicKey`: "pem" (default), "der", "base64"
177    /// - `RsaKeypair`: "pem" (default), "der" (private key in PKCS#8)
178    /// - Other artifacts: text representation (format ignored)
179    ///
180    /// MIME type references:
181    /// - application/pkcs7-mime: CMS/PKCS#7 (RFC 5273)
182    /// - application/pkcs8: PKCS#8 private keys (RFC 5958)
183    /// - application/x-pem-file: PEM-encoded files
184    /// - application/x-x509-ca-cert: X.509 certificates (Netscape)
185    /// - text/plain: Text artifacts
186    ///
187    /// Returns an error if an unsupported format is specified.
188    #[allow(clippy::too_many_lines)]
189    pub fn serialize(&self, format: Option<&str>) -> Result<SerializedArtifact, String> {
190        match self {
191            ArtifactValue::WrappedKey { data, algorithm } => {
192                let fmt = format.unwrap_or("der");
193                let (mime, ext) = match algorithm {
194                    WrapAlgorithm::CmsRsaCbc | WrapAlgorithm::CmsRsaGcm => {
195                        ("application/pkcs7-mime", "p7c")
196                    }
197                    _ => ("application/octet-stream", "bin"),
198                };
199                match fmt {
200                    "der" => Ok(SerializedArtifact {
201                        bytes: data.clone(),
202                        mime_type: Some(mime.to_string()),
203                        extension: ext,
204                    }),
205                    "pem" => {
206                        let label = match algorithm {
207                            WrapAlgorithm::CmsRsaCbc | WrapAlgorithm::CmsRsaGcm => "CMS",
208                            _ => "WRAPPED KEY",
209                        };
210                        Ok(SerializedArtifact {
211                            bytes: encode_pem(label, data).into_bytes(),
212                            mime_type: Some("application/x-pem-file".to_string()),
213                            extension: "pem",
214                        })
215                    }
216                    _ => Err(format!(
217                        "Invalid format '{fmt}' for WrappedKey (valid: der, pem)"
218                    )),
219                }
220            }
221
222            ArtifactValue::PublicKey { key_data, .. } => {
223                let fmt = format.unwrap_or("pem");
224                match fmt {
225                    "pem" => Ok(SerializedArtifact {
226                        bytes: encode_pem("PUBLIC KEY", key_data).into_bytes(),
227                        mime_type: Some("application/x-pem-file".to_string()),
228                        extension: "pem",
229                    }),
230                    "der" => Ok(SerializedArtifact {
231                        bytes: key_data.clone(),
232                        // Note: Using x509-ca-cert as conventional type for DER public keys
233                        // No official MIME type exists for bare SPKI DER
234                        mime_type: Some("application/x-x509-ca-cert".to_string()),
235                        extension: "der",
236                    }),
237                    "base64" => Ok(SerializedArtifact {
238                        bytes: base64_encode(key_data).into_bytes(),
239                        mime_type: Some("text/plain".to_string()),
240                        extension: "txt",
241                    }),
242                    _ => Err(format!(
243                        "Invalid format '{fmt}' for PublicKey (valid: pem, der, base64)"
244                    )),
245                }
246            }
247
248            ArtifactValue::BackendKey { public_key, .. } => {
249                if let Some(pub_key) = public_key {
250                    // Public key is available - serialize like PublicKey
251                    let fmt = format.unwrap_or("pem");
252                    match fmt {
253                        "pem" => Ok(SerializedArtifact {
254                            bytes: encode_pem("PUBLIC KEY", pub_key).into_bytes(),
255                            mime_type: Some("application/x-pem-file".to_string()),
256                            extension: "pem",
257                        }),
258                        "der" => Ok(SerializedArtifact {
259                            bytes: pub_key.clone(),
260                            mime_type: Some("application/x-x509-ca-cert".to_string()),
261                            extension: "der",
262                        }),
263                        "base64" => Ok(SerializedArtifact {
264                            bytes: base64_encode(pub_key).into_bytes(),
265                            mime_type: Some("text/plain".to_string()),
266                            extension: "txt",
267                        }),
268                        _ => Err(format!(
269                            "Invalid format '{fmt}' for BackendKey (valid: pem, der, base64)"
270                        )),
271                    }
272                } else {
273                    Err("Cannot export public key from non-exportable backend key".to_string())
274                }
275            }
276
277            ArtifactValue::Bytes(bytes) => Ok(SerializedArtifact {
278                bytes: bytes.clone(),
279                mime_type: Some("application/octet-stream".to_string()),
280                extension: "bin",
281            }),
282            ArtifactValue::Text(text) => Ok(SerializedArtifact {
283                bytes: text.as_bytes().to_vec(),
284                mime_type: Some("text/plain".to_string()),
285                extension: "txt",
286            }),
287
288            ArtifactValue::Certificate { der } => {
289                let fmt = format.unwrap_or("pem");
290                match fmt {
291                    "pem" => Ok(SerializedArtifact {
292                        bytes: encode_pem("CERTIFICATE", der).into_bytes(),
293                        mime_type: Some("application/x-pem-file".to_string()),
294                        extension: "pem",
295                    }),
296                    "der" => Ok(SerializedArtifact {
297                        bytes: der.clone(),
298                        mime_type: Some("application/pkix-cert".to_string()),
299                        extension: "der",
300                    }),
301                    _ => Err(format!(
302                        "Invalid format '{fmt}' for Certificate (valid: pem, der)"
303                    )),
304                }
305            }
306        }
307    }
308}
309
310#[cfg(test)]
311mod tests {
312    use super::*;
313
314    #[test]
315    fn test_public_key_format_validation() {
316        let public_der = b"PUBLIC_KEY_DER_DATA".to_vec();
317        let public_key = ArtifactValue::PublicKey {
318            key_data: public_der.clone(),
319            format: KeyFormat::Pem,
320        };
321
322        // Test default format (PEM)
323        let serialized = public_key.serialize(None).unwrap();
324        assert_eq!(serialized.extension, "pem");
325        assert_eq!(
326            serialized.mime_type,
327            Some("application/x-pem-file".to_string())
328        );
329
330        // Test explicit PEM format
331        let serialized = public_key.serialize(Some("pem")).unwrap();
332        assert_eq!(serialized.extension, "pem");
333        assert_eq!(
334            serialized.mime_type,
335            Some("application/x-pem-file".to_string())
336        );
337
338        // Test DER format
339        let serialized = public_key.serialize(Some("der")).unwrap();
340        assert_eq!(
341            serialized.bytes, public_der,
342            "DER format should output raw key data"
343        );
344        assert_eq!(serialized.extension, "der");
345        assert_eq!(
346            serialized.mime_type,
347            Some("application/x-x509-ca-cert".to_string())
348        );
349
350        // Test base64 format
351        let serialized = public_key.serialize(Some("base64")).unwrap();
352        assert_eq!(serialized.extension, "txt");
353        assert_eq!(serialized.mime_type, Some("text/plain".to_string()));
354
355        // Invalid format
356        let result = public_key.serialize(Some("json"));
357        assert!(result.is_err(), "Invalid format should return error");
358        assert!(
359            result.unwrap_err().contains("Invalid format 'json'"),
360            "Error message should mention invalid format"
361        );
362    }
363
364    #[test]
365    fn test_wrapped_key_format_validation() {
366        let cms_data = b"CMS_DATA".to_vec();
367        let wrapped = ArtifactValue::WrappedKey {
368            data: cms_data.clone(),
369            algorithm: WrapAlgorithm::CmsRsaGcm,
370        };
371
372        // Test default format (DER)
373        let serialized = wrapped.serialize(None).unwrap();
374        assert_eq!(
375            serialized.bytes, cms_data,
376            "Default format should output raw CMS data"
377        );
378        assert_eq!(serialized.extension, "p7c");
379        assert_eq!(
380            serialized.mime_type,
381            Some("application/pkcs7-mime".to_string())
382        );
383
384        // Test explicit DER format
385        let serialized = wrapped.serialize(Some("der")).unwrap();
386        assert_eq!(serialized.bytes, cms_data);
387        assert_eq!(serialized.extension, "p7c");
388        assert_eq!(
389            serialized.mime_type,
390            Some("application/pkcs7-mime".to_string())
391        );
392
393        // Test PEM format
394        let serialized = wrapped.serialize(Some("pem")).unwrap();
395        let pem_str = String::from_utf8(serialized.bytes).unwrap();
396        assert!(
397            pem_str.contains("-----BEGIN CMS-----"),
398            "PEM should contain CMS header"
399        );
400        assert_eq!(serialized.extension, "pem");
401        assert_eq!(
402            serialized.mime_type,
403            Some("application/x-pem-file".to_string())
404        );
405
406        // Invalid format
407        let result = wrapped.serialize(Some("base64"));
408        assert!(result.is_err(), "Invalid format should return error");
409        assert!(
410            result.unwrap_err().contains("Invalid format 'base64'"),
411            "Error message should mention invalid format"
412        );
413    }
414}