mirror of
https://github.com/rkivalin/certforge.git
synced 2026-10-13 16:00:44 +00:00
Rust CLI tool for automated ACME certificate management with: - DNS-01 challenge via RFC 2136 dynamic DNS updates with TSIG auth - DANE TLSA record publication (RFC 6698/7671/7672) - systemd credential integration (systemd-creds encrypt/decrypt) - systemd unit management via D-Bus (zbus) - Post-renewal hooks (systemctl reload/restart, arbitrary commands) - Key rotation with DANE pre-publication protocol - TOML configuration with per-zone DNS overrides Tested against Let's Encrypt staging. 12 unit tests, zero warnings. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
223 lines
7.5 KiB
Rust
223 lines
7.5 KiB
Rust
use ring::rand::SystemRandom;
|
|
use ring::signature::{self, EcdsaKeyPair, KeyPair as _};
|
|
|
|
use crate::config::KeyType;
|
|
use crate::credentials;
|
|
use crate::error::{Error, Result};
|
|
use std::path::Path;
|
|
|
|
/// A generated or loaded key pair with its type metadata.
|
|
pub struct CertKeyPair {
|
|
pub key_type: KeyType,
|
|
/// PKCS#8 v1 DER-encoded private key
|
|
pub pkcs8_der: Vec<u8>,
|
|
}
|
|
|
|
impl CertKeyPair {
|
|
/// Generate a new key pair of the specified type.
|
|
pub fn generate(key_type: &KeyType) -> Result<Self> {
|
|
let rng = SystemRandom::new();
|
|
|
|
let pkcs8_der = match key_type {
|
|
KeyType::EcdsaP256 => {
|
|
EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P256_SHA256_ASN1_SIGNING, &rng)
|
|
.map_err(|e| Error::Key(format!("failed to generate ECDSA P-256 key: {e}")))?
|
|
.as_ref()
|
|
.to_vec()
|
|
}
|
|
KeyType::EcdsaP384 => {
|
|
EcdsaKeyPair::generate_pkcs8(&signature::ECDSA_P384_SHA384_ASN1_SIGNING, &rng)
|
|
.map_err(|e| Error::Key(format!("failed to generate ECDSA P-384 key: {e}")))?
|
|
.as_ref()
|
|
.to_vec()
|
|
}
|
|
KeyType::Rsa2048 | KeyType::Rsa4096 => {
|
|
return Err(Error::Key(
|
|
"RSA key generation not yet supported; use ecdsa-p256 or ecdsa-p384".into(),
|
|
));
|
|
}
|
|
};
|
|
|
|
Ok(Self {
|
|
key_type: key_type.clone(),
|
|
pkcs8_der,
|
|
})
|
|
}
|
|
|
|
/// Load a key pair from a credential or file path.
|
|
pub async fn load(
|
|
credential: Option<&Path>,
|
|
path: Option<&Path>,
|
|
key_type: &KeyType,
|
|
) -> Result<Self> {
|
|
let pem_or_der = credentials::load_secret(credential, path).await?;
|
|
Self::from_pem_or_der(&pem_or_der, key_type)
|
|
}
|
|
|
|
/// Parse a key from PEM or raw DER bytes.
|
|
pub fn from_pem_or_der(data: &[u8], key_type: &KeyType) -> Result<Self> {
|
|
// Try PEM first
|
|
let der = if let Ok(pem) = pem::parse(data) {
|
|
pem.contents().to_vec()
|
|
} else {
|
|
data.to_vec()
|
|
};
|
|
|
|
// Validate the key can be parsed
|
|
match key_type {
|
|
KeyType::EcdsaP256 => {
|
|
EcdsaKeyPair::from_pkcs8(
|
|
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
|
|
&der,
|
|
&SystemRandom::new(),
|
|
)
|
|
.map_err(|e| Error::Key(format!("invalid ECDSA P-256 key: {e}")))?;
|
|
}
|
|
KeyType::EcdsaP384 => {
|
|
EcdsaKeyPair::from_pkcs8(
|
|
&signature::ECDSA_P384_SHA384_ASN1_SIGNING,
|
|
&der,
|
|
&SystemRandom::new(),
|
|
)
|
|
.map_err(|e| Error::Key(format!("invalid ECDSA P-384 key: {e}")))?;
|
|
}
|
|
KeyType::Rsa2048 | KeyType::Rsa4096 => {
|
|
return Err(Error::Key("RSA keys not yet supported".into()));
|
|
}
|
|
}
|
|
|
|
Ok(Self {
|
|
key_type: key_type.clone(),
|
|
pkcs8_der: der,
|
|
})
|
|
}
|
|
|
|
/// Encode the private key as PEM.
|
|
pub fn to_pem(&self) -> String {
|
|
pem::encode(&pem::Pem::new("PRIVATE KEY", self.pkcs8_der.clone()))
|
|
}
|
|
|
|
/// Extract the SubjectPublicKeyInfo (SPKI) in DER format.
|
|
pub fn spki_der(&self) -> Result<Vec<u8>> {
|
|
match self.key_type {
|
|
KeyType::EcdsaP256 => {
|
|
let kp = EcdsaKeyPair::from_pkcs8(
|
|
&signature::ECDSA_P256_SHA256_ASN1_SIGNING,
|
|
&self.pkcs8_der,
|
|
&SystemRandom::new(),
|
|
)
|
|
.map_err(|e| Error::Key(format!("failed to parse key for SPKI: {e}")))?;
|
|
// ring's public_key() returns the raw public key bytes.
|
|
// We need to wrap it in SPKI ASN.1 structure.
|
|
Ok(wrap_ec_spki(
|
|
kp.public_key().as_ref(),
|
|
EC_P256_OID,
|
|
))
|
|
}
|
|
KeyType::EcdsaP384 => {
|
|
let kp = EcdsaKeyPair::from_pkcs8(
|
|
&signature::ECDSA_P384_SHA384_ASN1_SIGNING,
|
|
&self.pkcs8_der,
|
|
&SystemRandom::new(),
|
|
)
|
|
.map_err(|e| Error::Key(format!("failed to parse key for SPKI: {e}")))?;
|
|
Ok(wrap_ec_spki(
|
|
kp.public_key().as_ref(),
|
|
EC_P384_OID,
|
|
))
|
|
}
|
|
KeyType::Rsa2048 | KeyType::Rsa4096 => {
|
|
Err(Error::Key("RSA SPKI extraction not yet supported".into()))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// OID for id-ecPublicKey (1.2.840.10045.2.1)
|
|
const EC_PUBLIC_KEY_OID: &[u8] = &[0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01];
|
|
|
|
// OID for secp256r1 (1.2.840.10045.3.1.7)
|
|
const EC_P256_OID: &[u8] = &[0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07];
|
|
|
|
// OID for secp384r1 (1.3.132.0.34)
|
|
const EC_P384_OID: &[u8] = &[0x06, 0x05, 0x2b, 0x81, 0x04, 0x00, 0x22];
|
|
|
|
/// Wrap an EC public key in a SubjectPublicKeyInfo ASN.1 structure.
|
|
///
|
|
/// SPKI = SEQUENCE {
|
|
/// algorithm AlgorithmIdentifier = SEQUENCE { id-ecPublicKey, namedCurve },
|
|
/// subjectPublicKey BIT STRING
|
|
/// }
|
|
fn wrap_ec_spki(public_key_bytes: &[u8], curve_oid: &[u8]) -> Vec<u8> {
|
|
// AlgorithmIdentifier = SEQUENCE { ecPublicKey OID, curve OID }
|
|
let alg_id_content_len = EC_PUBLIC_KEY_OID.len() + curve_oid.len();
|
|
let mut alg_id = vec![0x30]; // SEQUENCE
|
|
encode_asn1_length(alg_id_content_len, &mut alg_id);
|
|
alg_id.extend_from_slice(EC_PUBLIC_KEY_OID);
|
|
alg_id.extend_from_slice(curve_oid);
|
|
|
|
// BIT STRING wrapping the public key
|
|
// BIT STRING = 0x03 | length | 0x00 (no unused bits) | public_key_bytes
|
|
let bit_string_content_len = 1 + public_key_bytes.len();
|
|
let mut bit_string = vec![0x03];
|
|
encode_asn1_length(bit_string_content_len, &mut bit_string);
|
|
bit_string.push(0x00); // no unused bits
|
|
bit_string.extend_from_slice(public_key_bytes);
|
|
|
|
// SPKI = SEQUENCE { alg_id, bit_string }
|
|
let spki_content_len = alg_id.len() + bit_string.len();
|
|
let mut spki = vec![0x30]; // SEQUENCE
|
|
encode_asn1_length(spki_content_len, &mut spki);
|
|
spki.extend_from_slice(&alg_id);
|
|
spki.extend_from_slice(&bit_string);
|
|
|
|
spki
|
|
}
|
|
|
|
fn encode_asn1_length(len: usize, buf: &mut Vec<u8>) {
|
|
if len < 0x80 {
|
|
buf.push(len as u8);
|
|
} else if len < 0x100 {
|
|
buf.push(0x81);
|
|
buf.push(len as u8);
|
|
} else {
|
|
buf.push(0x82);
|
|
buf.push((len >> 8) as u8);
|
|
buf.push(len as u8);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn generate_ecdsa_p256() {
|
|
let kp = CertKeyPair::generate(&KeyType::EcdsaP256).unwrap();
|
|
assert!(!kp.pkcs8_der.is_empty());
|
|
let pem = kp.to_pem();
|
|
assert!(pem.starts_with("-----BEGIN PRIVATE KEY-----"));
|
|
|
|
let spki = kp.spki_der().unwrap();
|
|
assert!(!spki.is_empty());
|
|
// SPKI should start with SEQUENCE tag
|
|
assert_eq!(spki[0], 0x30);
|
|
}
|
|
|
|
#[test]
|
|
fn generate_ecdsa_p384() {
|
|
let kp = CertKeyPair::generate(&KeyType::EcdsaP384).unwrap();
|
|
assert!(!kp.pkcs8_der.is_empty());
|
|
let spki = kp.spki_der().unwrap();
|
|
assert!(!spki.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn roundtrip_pem() {
|
|
let kp = CertKeyPair::generate(&KeyType::EcdsaP256).unwrap();
|
|
let pem_str = kp.to_pem();
|
|
let kp2 = CertKeyPair::from_pem_or_der(pem_str.as_bytes(), &KeyType::EcdsaP256).unwrap();
|
|
assert_eq!(kp.pkcs8_der, kp2.pkcs8_der);
|
|
}
|
|
}
|