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, EncryptedData, KeyAlgorithm, KeyCheckValue, KeyId, PublicKeyDer, WrapScheme,
7    WrappedKey,
8};
9use secrecy::{ExposeSecret, SecretBox};
10
11/// Runtime representation of an artifact.
12#[derive(Debug)]
13pub enum ArtifactValue {
14    /// Cryptographic key managed by a backend (software, HSM, `YubiKey`).
15    /// The private key never leaves the backend - only references are stored.
16    BackendKey {
17        /// Name of the backend that owns this key (e.g., "software", "yubikey1").
18        backend_name: String,
19        /// Backend-specific key identifier (opaque, e.g., UUID or "`slot_9c`").
20        key_id: KeyId,
21        /// Algorithm for type checking and evidence.
22        algorithm: KeyAlgorithm,
23        /// Public key (None for non-exportable HSM keys).
24        public_key: Option<PublicKeyDer>,
25        /// Key check value, for a symmetric key.
26        ///
27        /// What the key answers to where a keypair is answered for by its
28        /// public half. A ceremony reaches it as `${artifact.kek.kcv | hex}`,
29        /// which is how a custodian compares it against what the token shows.
30        check_value: Option<KeyCheckValue>,
31    },
32    /// Wrapped key, with the scheme it was wrapped under and the algorithms
33    /// the wrap actually used. The container follows the scheme; the OpenSSL
34    /// backend produces a CMS `ContentInfo`.
35    WrappedKey(WrappedKey),
36    /// Content encrypted for a recipient, in the container its scheme names.
37    ///
38    /// Distinct from [`WrappedKey`](ArtifactValue::WrappedKey) even though the
39    /// bytes have the same shape. What differs is the claim: a wrapped key left
40    /// a backend under protection, and encrypted content says nothing about
41    /// custody. The type is what keeps a document from reaching a step that
42    /// installs keys.
43    EncryptedData(EncryptedData),
44    /// Exported public key.
45    PublicKey(PublicKeyDer),
46    /// Binary content from files or inline data (documents, crypto materials).
47    /// Used for hashing, cryptographic operations, and verification.
48    Bytes(Vec<u8>),
49    /// Content a step opened, wiped when the artifact is dropped.
50    ///
51    /// The same shape as [`Bytes`](ArtifactValue::Bytes) and a different
52    /// claim: these bytes came out of a container that existed to keep them
53    /// unreadable. A step reads them the way it reads any bytes, and an
54    /// output writes them like any other artifact; declaring the output is
55    /// the author's decision to put them on disk. Nothing displays them: the
56    /// wrapper redacts its `Debug` output and hands the bytes out only
57    /// through `expose_secret`.
58    Secret(SecretBox<Vec<u8>>),
59    /// The shares of a split secret, wiped when the artifact is dropped.
60    ///
61    /// One artifact for the whole set, so the transcript and the all-subsets
62    /// check see one thing, and a step names one share as a property:
63    /// `${artifact.shares.share_2}` is the share evaluated at 2. A share is
64    /// read only through `reads:`, never rendered by an expression, since
65    /// the copy an expression makes is unwiped JSON and a prompt text is a
66    /// transcript fact. The set is not written to the run directory at all;
67    /// shares go onto paper, through a step that shows one at a time. A set
68    /// of one is how a share typed back at a recovery arrives.
69    Shares(ShareSet),
70
71    /// Display text for physical item references (USB drives, tamper bags, etc.).
72    /// Used in messages and prompts when referencing physical objects.
73    Text(String),
74
75    /// X.509 certificate. Stored as DER; displayed and serialized as PEM.
76    Certificate(CertificateDer),
77}
78
79/// The shares of one split, as each custodian receives them.
80///
81/// The runtime holds the parts and nothing of the arithmetic: what it needs
82/// is to hand one share to a step that names it under `reads:`, and to say
83/// how many there are. A share has no canonical byte form here; laying it
84/// out is a container's job, in a file or on paper. Each share's `y` is
85/// wiped when the set is dropped, and `Debug` prints counts only.
86pub struct ShareSet {
87    threshold: u8,
88    shares: Vec<Share>,
89}
90
91/// One share of a split: the threshold of its set, the `x` it was
92/// evaluated at (from 1) and one `y` byte per byte of the secret.
93pub struct Share {
94    threshold: u8,
95    index: u8,
96    y: SecretBox<Vec<u8>>,
97}
98
99impl Share {
100    /// A share from its parts.
101    pub fn new(threshold: u8, index: u8, y: Vec<u8>) -> Self {
102        Self {
103            threshold,
104            index,
105            y: SecretBox::new(Box::new(y)),
106        }
107    }
108
109    /// How many shares reconstruct the secret this one belongs to.
110    pub fn threshold(&self) -> u8 {
111        self.threshold
112    }
113
114    /// The `x` this share was evaluated at, from 1.
115    pub fn index(&self) -> u8 {
116        self.index
117    }
118
119    /// The `y` values, borrowed.
120    pub fn y(&self) -> &[u8] {
121        self.y.expose_secret()
122    }
123}
124
125impl std::fmt::Debug for Share {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        write!(
128            f,
129            "Share(index={}, threshold={}, {} bytes)",
130            self.index,
131            self.threshold,
132            self.y.expose_secret().len()
133        )
134    }
135}
136
137impl ShareSet {
138    /// A set of `shares`, index 1 first, of which `threshold` reconstruct
139    /// the secret.
140    pub fn new(threshold: u8, shares: impl IntoIterator<Item = Share>) -> Self {
141        Self {
142            threshold,
143            shares: shares.into_iter().collect(),
144        }
145    }
146
147    /// How many shares reconstruct the secret.
148    pub fn threshold(&self) -> u8 {
149        self.threshold
150    }
151
152    /// How many shares there are.
153    pub fn count(&self) -> u8 {
154        u8::try_from(self.shares.len()).unwrap_or(u8::MAX)
155    }
156
157    /// The share with this index, from 1.
158    pub fn share(&self, index: u8) -> Option<&Share> {
159        self.shares.iter().find(|share| share.index == index)
160    }
161
162    /// The one share of a set that holds exactly one, as a typed-back share
163    /// arrives; `None` for a set of several, which has to be named.
164    pub fn only(&self) -> Option<&Share> {
165        match self.shares.as_slice() {
166            [share] => Some(share),
167            _ => None,
168        }
169    }
170}
171
172impl std::fmt::Debug for ShareSet {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        write!(
175            f,
176            "ShareSet({}-of-{}, {} bytes each)",
177            self.threshold,
178            self.shares.len(),
179            self.shares
180                .first()
181                .map_or(0, |share| share.y.expose_secret().len())
182        )
183    }
184}
185
186impl std::fmt::Display for ArtifactValue {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            // Real cryptographic artifacts
190            ArtifactValue::BackendKey {
191                backend_name,
192                key_id,
193                algorithm,
194                public_key,
195                check_value,
196            } => {
197                // Output backend key metadata and public key if available
198                if let Some(pub_key) = public_key {
199                    write!(
200                        f,
201                        "BackendKey(backend={backend_name}, key_id={}, algorithm={algorithm:?})\n{}",
202                        key_id.as_str(),
203                        encode_pem("PUBLIC KEY", pub_key.as_bytes())
204                    )
205                } else if let Some(check_value) = check_value {
206                    // A symmetric key has no public half to be missing, and the
207                    // check value is what names it everywhere else. Saying
208                    // `not_exportable` here would read as a backend refusing an
209                    // export that was never possible.
210                    write!(
211                        f,
212                        "BackendKey(backend={backend_name}, key_id={}, algorithm={algorithm:?}, kcv={check_value})",
213                        key_id.as_str()
214                    )
215                } else {
216                    write!(
217                        f,
218                        "BackendKey(backend={backend_name}, key_id={}, algorithm={algorithm:?}, public_key=not_exportable)",
219                        key_id.as_str()
220                    )
221                }
222            }
223            ArtifactValue::WrappedKey(wrapped) => match pem_label(wrapped.scheme()) {
224                Some(label) => write!(f, "{}", encode_pem(label, wrapped.data())),
225                // Raw mechanism output has no PEM form; say what it is and how
226                // big rather than printing bytes that look like a document.
227                None => write!(
228                    f,
229                    "WrappedKey({}, {} bytes)",
230                    wrapped.scheme(),
231                    wrapped.data().len()
232                ),
233            },
234            ArtifactValue::EncryptedData(encrypted) => write!(
235                f,
236                "EncryptedData({}, {} bytes)",
237                encrypted.scheme(),
238                encrypted.data().len()
239            ),
240            ArtifactValue::PublicKey(key) => {
241                let pem = encode_pem("PUBLIC KEY", key.as_bytes());
242                write!(f, "{pem}")
243            }
244            // Materials
245            ArtifactValue::Bytes(bytes) => {
246                let len = bytes.len();
247                write!(f, "Bytes({len} bytes)")
248            }
249            ArtifactValue::Secret(bytes) => {
250                let len = bytes.expose_secret().len();
251                write!(f, "Secret({len} bytes)")
252            }
253            ArtifactValue::Shares(set) => write!(f, "{set:?}"),
254            ArtifactValue::Text(text) => {
255                write!(f, "Text({text})")
256            }
257
258            ArtifactValue::Certificate(certificate) => {
259                let pem = encode_pem("CERTIFICATE", certificate.as_bytes());
260                write!(f, "{pem}")
261            }
262        }
263    }
264}
265
266/// Encode bytes as base64 (standard alphabet with padding).
267/// Uses constant-time encoding from `RustCrypto` `base64ct`.
268pub(crate) fn base64_encode(data: &[u8]) -> String {
269    Base64::encode_string(data)
270}
271
272/// The PEM label for a wrapped key, where the container has one.
273///
274/// `None` for a raw mechanism: its output is bare ciphertext with no structure
275/// and no registered PEM type, and inventing a label would produce a file that
276/// looks like something a tool can read and is not.
277fn pem_label(scheme: WrapScheme) -> Option<&'static str> {
278    match scheme {
279        WrapScheme::CmsAes256Gcm => Some("CMS"),
280        _ => None,
281    }
282}
283
284/// Write out a container the way a recipient reads it.
285///
286/// Shared by a wrapped key and encrypted content, because the scheme decides
287/// the encoding and the two carry the same schemes. `kind` names the artifact
288/// in the error, so a wrong `format:` says which artifact it was written on.
289fn serialize_container(
290    scheme: WrapScheme,
291    data: &[u8],
292    format: Option<&str>,
293    kind: &str,
294) -> Result<SerializedArtifact, String> {
295    let (mime, ext) = match scheme {
296        WrapScheme::CmsAes256Gcm => ("application/pkcs7-mime", "p7c"),
297        _ => ("application/octet-stream", "bin"),
298    };
299    match format.unwrap_or("der") {
300        "der" => Ok(SerializedArtifact {
301            bytes: data.to_vec(),
302            mime_type: Some(mime.to_string()),
303            extension: ext,
304        }),
305        "pem" => {
306            let label = pem_label(scheme).ok_or_else(|| {
307                format!(
308                    "{scheme} produces raw bytes, which have no PEM form. Write it as 'der', \
309                     which is what a recipient imports."
310                )
311            })?;
312            Ok(SerializedArtifact {
313                bytes: encode_pem(label, data).into_bytes(),
314                mime_type: Some("application/x-pem-file".to_string()),
315                extension: "pem",
316            })
317        }
318        other => Err(format!(
319            "Invalid format '{other}' for {kind} (valid: der, pem)"
320        )),
321    }
322}
323
324/// Encode DER bytes as PEM with given label.
325pub(crate) fn encode_pem(label: &str, der_bytes: &[u8]) -> String {
326    let b64 = base64_encode(der_bytes);
327    // Split into 64-character lines (PEM standard)
328    let lines: Vec<&str> = b64
329        .as_bytes()
330        .chunks(64)
331        .map(|chunk| std::str::from_utf8(chunk).unwrap_or(""))
332        .collect();
333
334    let body = lines.join("\n");
335    format!("-----BEGIN {label}-----\n{body}\n-----END {label}-----")
336}
337
338/// A serialized artifact ready for output.
339///
340/// Contains both the serialized bytes and metadata about the content.
341/// Produced by calling `artifact.serialize(format)`.
342///
343/// Metadata includes:
344/// - MIME type (see <https://pki-tutorial.readthedocs.io/en/latest/mime.html>)
345/// - File extension appropriate for the format
346#[derive(Debug, Clone, PartialEq, Eq)]
347pub struct SerializedArtifact {
348    /// Serialized content bytes.
349    pub bytes: Vec<u8>,
350    /// MIME type for the artifact in the specified format.
351    pub mime_type: Option<String>,
352    /// File extension appropriate for the format (without leading dot).
353    pub extension: &'static str,
354}
355
356impl ArtifactValue {
357    /// Serialize artifact to bytes with metadata.
358    ///
359    /// Returns a `SerializedArtifact` containing both the serialized bytes
360    /// and metadata (MIME type, file extension) appropriate for the format.
361    ///
362    /// Applies sensible defaults if format is None, then validates strictly.
363    ///
364    /// Supported formats by artifact type:
365    /// - `WrappedKey`: "der" (default), "pem"
366    /// - `PublicKey`: "pem" (default), "der", "base64"
367    /// - `RsaKeypair`: "pem" (default), "der" (private key in PKCS#8)
368    /// - Other artifacts: text representation (format ignored)
369    ///
370    /// MIME type references:
371    /// - application/pkcs7-mime: CMS/PKCS#7 (RFC 5273)
372    /// - application/pkcs8: PKCS#8 private keys (RFC 5958)
373    /// - application/x-pem-file: PEM-encoded files
374    /// - application/x-x509-ca-cert: X.509 certificates (Netscape)
375    /// - text/plain: Text artifacts
376    ///
377    /// Returns an error if an unsupported format is specified.
378    pub fn serialize(&self, format: Option<&str>) -> Result<SerializedArtifact, String> {
379        match self {
380            ArtifactValue::WrappedKey(wrapped) => {
381                serialize_container(wrapped.scheme(), wrapped.data(), format, "WrappedKey")
382            }
383            ArtifactValue::EncryptedData(encrypted) => serialize_container(
384                encrypted.scheme(),
385                encrypted.data(),
386                format,
387                "EncryptedData",
388            ),
389
390            ArtifactValue::PublicKey(key) => serialize_der(key.as_bytes(), &PUBLIC_KEY, format),
391
392            ArtifactValue::BackendKey { public_key, .. } => match public_key {
393                Some(key) => serialize_der(key.as_bytes(), &PUBLIC_KEY, format),
394                None => Err("Cannot export public key from non-exportable backend key".to_string()),
395            },
396
397            ArtifactValue::Bytes(bytes) => Ok(SerializedArtifact {
398                bytes: bytes.clone(),
399                mime_type: Some("application/octet-stream".to_string()),
400                extension: "bin",
401            }),
402            ArtifactValue::Secret(bytes) => Ok(SerializedArtifact {
403                bytes: bytes.expose_secret().clone(),
404                mime_type: Some("application/octet-stream".to_string()),
405                extension: "bin",
406            }),
407            // A file holding every share is the secret in the clear under
408            // another name. Shares leave the machine one at a time, on paper.
409            ArtifactValue::Shares(_) => Err(
410                "A share set is not written to disk; each share is shown to its custodian \
411                 by a step that reads it"
412                    .to_string(),
413            ),
414            ArtifactValue::Text(text) => Ok(SerializedArtifact {
415                bytes: text.as_bytes().to_vec(),
416                mime_type: Some("text/plain".to_string()),
417                extension: "txt",
418            }),
419
420            ArtifactValue::Certificate(certificate) => {
421                serialize_der(certificate.as_bytes(), &CERTIFICATE, format)
422            }
423        }
424    }
425}
426
427/// How one kind of DER artifact renders.
428///
429/// Public keys and certificates take the same three formats and differ only in
430/// their PEM label, their DER media type, and how they are named when a format
431/// is refused, so [`serialize_der`] holds the ladder once.
432struct DerArtifact {
433    pem_label: &'static str,
434    der_mime: &'static str,
435    name: &'static str,
436}
437
438/// x509-ca-cert by convention: no official media type exists for bare SPKI DER.
439const PUBLIC_KEY: DerArtifact = DerArtifact {
440    pem_label: "PUBLIC KEY",
441    der_mime: "application/x-x509-ca-cert",
442    name: "a public key",
443};
444
445const CERTIFICATE: DerArtifact = DerArtifact {
446    pem_label: "CERTIFICATE",
447    der_mime: "application/pkix-cert",
448    name: "a certificate",
449};
450
451/// Render DER material in the format a ceremony asked for, PEM by default.
452fn serialize_der(
453    der: &[u8],
454    artifact: &DerArtifact,
455    format: Option<&str>,
456) -> Result<SerializedArtifact, String> {
457    let fmt = format.unwrap_or("pem");
458    match fmt {
459        "pem" => Ok(SerializedArtifact {
460            bytes: encode_pem(artifact.pem_label, der).into_bytes(),
461            mime_type: Some("application/x-pem-file".to_string()),
462            extension: "pem",
463        }),
464        "der" => Ok(SerializedArtifact {
465            bytes: der.to_vec(),
466            mime_type: Some(artifact.der_mime.to_string()),
467            extension: "der",
468        }),
469        "base64" => Ok(SerializedArtifact {
470            bytes: base64_encode(der).into_bytes(),
471            mime_type: Some("text/plain".to_string()),
472            extension: "txt",
473        }),
474        _ => Err(format!(
475            "Invalid format '{fmt}' for {} (valid: pem, der, base64)",
476            artifact.name
477        )),
478    }
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484
485    /// A P-256 public key in SPKI DER, so the artifact holds a real one.
486    ///
487    /// Reproduce with:
488    /// `openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 |
489    ///  openssl pkey -pubout -outform DER`
490    const P256_SPKI: &[u8] = include_bytes!("testdata/p256.spki.der");
491
492    #[test]
493    fn a_public_key_serializes_as_pem_by_default_and_der_on_request() {
494        let public_der = P256_SPKI.to_vec();
495        let public_key =
496            ArtifactValue::PublicKey(PublicKeyDer::new(public_der.clone()).expect("valid SPKI"));
497
498        // Default format
499        let serialized = public_key.serialize(None).unwrap();
500        assert_eq!(serialized.extension, "pem");
501        assert_eq!(
502            serialized.mime_type,
503            Some("application/x-pem-file".to_string())
504        );
505
506        // Explicit PEM
507        let serialized = public_key.serialize(Some("pem")).unwrap();
508        assert_eq!(serialized.extension, "pem");
509        assert_eq!(
510            serialized.mime_type,
511            Some("application/x-pem-file".to_string())
512        );
513
514        // DER
515        let serialized = public_key.serialize(Some("der")).unwrap();
516        assert_eq!(
517            serialized.bytes, public_der,
518            "DER format should output raw key data"
519        );
520        assert_eq!(serialized.extension, "der");
521        assert_eq!(
522            serialized.mime_type,
523            Some("application/x-x509-ca-cert".to_string())
524        );
525
526        // Base64
527        let serialized = public_key.serialize(Some("base64")).unwrap();
528        assert_eq!(serialized.extension, "txt");
529        assert_eq!(serialized.mime_type, Some("text/plain".to_string()));
530
531        // Invalid format
532        let result = public_key.serialize(Some("json"));
533        assert!(result.is_err(), "Invalid format should return error");
534        assert!(
535            result.unwrap_err().contains("Invalid format 'json'"),
536            "Error message should mention invalid format"
537        );
538    }
539
540    /// A file of every share would be the secret under another name, so
541    /// `creates:` on a split is held in memory and never serialized.
542    #[test]
543    fn a_share_set_is_never_written_to_disk() {
544        let set = ShareSet::new(
545            2,
546            [
547                Share::new(2, 1, vec![0xCD; 8]),
548                Share::new(2, 2, vec![0xEF; 8]),
549            ],
550        );
551        let shares = ArtifactValue::Shares(set);
552        for format in [None, Some("bin"), Some("pem")] {
553            let error = shares
554                .serialize(format)
555                .expect_err("shares have no file form");
556            assert!(error.contains("not written to disk"), "{error}");
557        }
558    }
559
560    #[test]
561    fn a_wrapped_key_serializes_as_der_by_default_and_pem_on_request() {
562        let cms_data = b"CMS_DATA".to_vec();
563        let description = rite_sdk::WrapDescription::new(
564            rite_sdk::RecipientInfoKind::Ktri,
565            rite_sdk::Oid::new(rite_sdk::oid::RSA_ENCRYPTION).unwrap(),
566        )
567        .with_content_encryption(rite_sdk::Oid::new(rite_sdk::oid::AES_256_GCM).unwrap());
568        let wrapped = ArtifactValue::WrappedKey(
569            WrappedKey::new(WrapScheme::CmsAes256Gcm, description, cms_data.clone()).unwrap(),
570        );
571
572        // Default format
573        let serialized = wrapped.serialize(None).unwrap();
574        assert_eq!(
575            serialized.bytes, cms_data,
576            "Default format should output raw CMS data"
577        );
578        assert_eq!(serialized.extension, "p7c");
579        assert_eq!(
580            serialized.mime_type,
581            Some("application/pkcs7-mime".to_string())
582        );
583
584        // Explicit DER
585        let serialized = wrapped.serialize(Some("der")).unwrap();
586        assert_eq!(serialized.bytes, cms_data);
587        assert_eq!(serialized.extension, "p7c");
588        assert_eq!(
589            serialized.mime_type,
590            Some("application/pkcs7-mime".to_string())
591        );
592
593        // PEM
594        let serialized = wrapped.serialize(Some("pem")).unwrap();
595        let pem_str = String::from_utf8(serialized.bytes).unwrap();
596        assert!(
597            pem_str.contains("-----BEGIN CMS-----"),
598            "PEM should contain CMS header"
599        );
600        assert_eq!(serialized.extension, "pem");
601        assert_eq!(
602            serialized.mime_type,
603            Some("application/x-pem-file".to_string())
604        );
605
606        // Invalid format
607        let result = wrapped.serialize(Some("base64"));
608        assert!(result.is_err(), "Invalid format should return error");
609        assert!(
610            result.unwrap_err().contains("Invalid format 'base64'"),
611            "Error message should mention invalid format"
612        );
613    }
614}