Skip to main content

rite_runtime/actions/
types.rs

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