Skip to main content

vault_core/
generators.rs

1//! Cryptographic key and certificate generators.
2//!
3//! Every generator here takes an [`crate::entropy::Source`]. That is the whole
4//! reason this module changed in Phase 17: the CLI's secret and password
5//! generators used `rand::thread_rng()` while these two used `OsRng`, so there
6//! was no single seam an entropy source could attach to. There is one now, and
7//! the source reaches the key material rather than being applied to it
8//! afterwards — `rcgen` and `ssh-key` both generate their own keys, so bytes
9//! have to go *in*, not be mixed in after the fact.
10
11use crate::entropy::{EntropyRng, Source};
12use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair};
13use time::Duration;
14
15/// PKCS#8 v1 prefix for an Ed25519 private key: SEQUENCE, version 0,
16/// AlgorithmIdentifier 1.3.101.112, OCTET STRING wrapping the 32-byte seed.
17///
18/// Fixed because the structure is fixed — every Ed25519 PKCS#8 key differs only
19/// in its last 32 bytes. Building it here is what lets our entropy decide the
20/// certificate key, since `rcgen::KeyPair::generate` takes no RNG.
21const ED25519_PKCS8_PREFIX: [u8; 16] = [
22    0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20,
23];
24
25/// Generates a self-signed X.509 certificate and private key PEM pair.
26///
27/// Returns `{"cert_pem": "...", "key_pem": "..."}`.
28pub fn generate_certificate(
29    common_name: &str,
30    validity_days: u32,
31    source: &Source,
32) -> Result<serde_json::Value, String> {
33    let key_pair = if source.is_external() {
34        // Our bytes become the key. `KeyPair::generate()` would use ring's own
35        // RNG and quietly ignore the source the user selected.
36        let mut seed = [0u8; 32];
37        crate::entropy::fill(source, "cert-ed25519", &mut seed)?;
38        let mut der = Vec::with_capacity(48);
39        der.extend_from_slice(&ED25519_PKCS8_PREFIX);
40        der.extend_from_slice(&seed);
41        KeyPair::from_pkcs8_der_and_sign_algo(&der.into(), &rcgen::PKCS_ED25519)
42            .map_err(|e| e.to_string())?
43    } else {
44        KeyPair::generate().map_err(|e| e.to_string())?
45    };
46    let mut params =
47        CertificateParams::new(vec![common_name.to_string()]).map_err(|e| e.to_string())?;
48    let mut dn = DistinguishedName::new();
49    dn.push(DnType::CommonName, common_name);
50    params.distinguished_name = dn;
51    params.not_after =
52        time::OffsetDateTime::now_utc() + Duration::days(validity_days.max(1) as i64);
53    let cert = params.self_signed(&key_pair).map_err(|e| e.to_string())?;
54    Ok(serde_json::json!({
55        "cert_pem": cert.pem(),
56        "key_pem":  key_pair.serialize_pem(),
57        "entropy_source": source.label(),
58    }))
59}
60
61/// Generates an Ed25519 SSH key pair in OpenSSH format.
62///
63/// Returns `{"public_key": "...", "private_key": "..."}`.
64pub fn generate_ssh_keypair(comment: &str, source: &Source) -> Result<serde_json::Value, String> {
65    use ssh_key::{Algorithm, LineEnding, PrivateKey};
66
67    // Fail before generating rather than during: `RngCore::fill_bytes` cannot
68    // return an error, so a device that disappears mid-generation can only
69    // panic. Checking here turns the common case (device not plugged in) into a
70    // clean message.
71    if let crate::entropy::Availability::Missing(why) = source.availability() {
72        return Err(format!("Entropy source {source} is unavailable: {why}"));
73    }
74    let mut rng = EntropyRng::new(source.clone(), "ssh-ed25519");
75    let mut private_key =
76        PrivateKey::random(&mut rng, Algorithm::Ed25519).map_err(|e| e.to_string())?;
77    private_key.set_comment(comment);
78    let public_key_str = private_key
79        .public_key()
80        .to_openssh()
81        .map_err(|e| e.to_string())?;
82    let private_key_str = private_key
83        .to_openssh(LineEnding::LF)
84        .map_err(|e| e.to_string())?;
85    Ok(serde_json::json!({
86        "public_key":  public_key_str,
87        "private_key": private_key_str.to_string(),
88        "entropy_source": source.label(),
89    }))
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn a_cert_from_our_own_entropy_is_still_a_valid_cert() {
98        // The PKCS#8 wrapping is hand-built, so this asserts the bytes rcgen
99        // accepts are the bytes we produced — a wrong prefix would fail here
100        // rather than in the field.
101        let src = Source::File {
102            path: "/dev/urandom".into(),
103        };
104        if src.availability() != crate::entropy::Availability::Ready {
105            return;
106        }
107        let v = generate_certificate("example.test", 30, &src).expect("generate");
108        assert!(v["cert_pem"]
109            .as_str()
110            .unwrap()
111            .starts_with("-----BEGIN CERTIFICATE-----"));
112        assert!(v["key_pem"].as_str().unwrap().contains("PRIVATE KEY"));
113        assert_eq!(v["entropy_source"], "file:/dev/urandom");
114    }
115
116    #[test]
117    fn ssh_keys_differ_between_generations() {
118        let a = generate_ssh_keypair("a", &Source::Os).unwrap();
119        let b = generate_ssh_keypair("b", &Source::Os).unwrap();
120        assert_ne!(a["private_key"], b["private_key"]);
121    }
122
123    #[test]
124    fn an_unavailable_source_refuses_before_generating() {
125        let src = Source::File {
126            path: "/nonexistent/device".into(),
127        };
128        assert!(generate_ssh_keypair("x", &src).is_err());
129        assert!(generate_certificate("x.test", 30, &src).is_err());
130    }
131}