feat: native step-ca signing for iLO cert provisioning
Replace the step CLI shell-out with direct step-ca API calls: - Fetch provisioner config and encrypted JWK key from /1.0/provisioners - Decrypt PBES2-HS256+A128KW JWE with AES key unwrap (RFC 3394) - Create ES256 JWT one-time token with CSR subject, SANs, and SHA - POST CSR + OTT to /1.0/sign Also fixes CSR reuse to validate CN matches before reusing a pending CSR, and auto-discovers iLO 4 (Hp) vs iLO 5 (Hpe) action URLs from the HttpsCert endpoint. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -437,6 +437,7 @@ dependencies = [
|
||||
name = "cichlid"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"clap",
|
||||
"openssl",
|
||||
"reqwest",
|
||||
@@ -444,6 +445,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
base64 = "0.22"
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
openssl = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
@@ -11,3 +12,4 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tracing = { workspace = true }
|
||||
uuid = { workspace = true, features = ["v4"] }
|
||||
|
||||
@@ -2,10 +2,11 @@ use clap::Parser;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
use super::step_ca;
|
||||
|
||||
#[derive(Parser)]
|
||||
pub struct Args {
|
||||
/// iLO hostname or IP address (e.g. ilo-frootmig.kosherinata.internal or 10.3.119.10)
|
||||
@@ -120,6 +121,8 @@ struct HttpsCertResponse {
|
||||
certificate_signing_request: Option<String>,
|
||||
#[serde(rename = "X509CertificateInformation")]
|
||||
x509_certificate_information: Option<CertInfo>,
|
||||
#[serde(rename = "Actions")]
|
||||
actions: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -133,6 +136,47 @@ struct CertInfo {
|
||||
valid_not_after: String,
|
||||
}
|
||||
|
||||
/// Discovered action URLs from the HttpsCert endpoint.
|
||||
/// Supports both iLO 4 (Hp prefix) and iLO 5+ (Hpe prefix).
|
||||
struct ActionUrls {
|
||||
generate_csr: String,
|
||||
import_cert: String,
|
||||
}
|
||||
|
||||
fn discover_action_urls(actions: &serde_json::Value, base_url: &str) -> Option<ActionUrls> {
|
||||
let actions_obj = actions.as_object()?;
|
||||
|
||||
let csr_target = actions_obj
|
||||
.iter()
|
||||
.find(|(k, _)| k.contains("GenerateCSR"))
|
||||
.and_then(|(_, v)| v.get("target"))
|
||||
.and_then(|t| t.as_str())?;
|
||||
|
||||
let import_target = actions_obj
|
||||
.iter()
|
||||
.find(|(k, _)| k.contains("ImportCertificate"))
|
||||
.and_then(|(_, v)| v.get("target"))
|
||||
.and_then(|t| t.as_str())?;
|
||||
|
||||
Some(ActionUrls {
|
||||
generate_csr: format!("{}{}", base_url, csr_target),
|
||||
import_cert: format!("{}{}", base_url, import_target),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a PEM-encoded CSR contains the expected CN.
|
||||
fn csr_has_cn(pem: &str, expected_cn: &str) -> bool {
|
||||
let Ok(der) = openssl::x509::X509Req::from_pem(pem.as_bytes()) else {
|
||||
return false;
|
||||
};
|
||||
let subject = der.subject_name();
|
||||
subject
|
||||
.entries_by_nid(openssl::nid::Nid::COMMONNAME)
|
||||
.next()
|
||||
.and_then(|e| e.data().as_utf8().ok())
|
||||
.is_some_and(|cn| cn.to_string() == expected_cn)
|
||||
}
|
||||
|
||||
fn build_client() -> Result<Client, Box<dyn std::error::Error>> {
|
||||
Ok(Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
@@ -178,122 +222,134 @@ pub async fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
println!("Authenticated to {}", base_url);
|
||||
|
||||
// Step 2: Generate a CSR
|
||||
let csr_action_url = format!(
|
||||
"{}/redfish/v1/Managers/1/SecurityService/HttpsCert/Actions/HpHttpsCert.GenerateCSR/",
|
||||
base_url
|
||||
);
|
||||
println!("Generating CSR for CN={}...", cn);
|
||||
|
||||
let csr_resp = client
|
||||
.post(&csr_action_url)
|
||||
.header("X-Auth-Token", &token)
|
||||
.json(&CsrRequest {
|
||||
action: "GenerateCSR".to_string(),
|
||||
country: args.country,
|
||||
state: args.state,
|
||||
city: args.city,
|
||||
org_name: args.org,
|
||||
org_unit: args.org_unit,
|
||||
common_name: cn.clone(),
|
||||
include_ip: args.include_ip,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !csr_resp.status().is_success() {
|
||||
let body = csr_resp.text().await.unwrap_or_default();
|
||||
return Err(format!("CSR generation request failed: {}", body).into());
|
||||
}
|
||||
|
||||
// Step 3: Poll for CSR
|
||||
// Step 2: Discover action URLs from the HttpsCert endpoint
|
||||
let cert_url = format!(
|
||||
"{}/redfish/v1/Managers/1/SecurityService/HttpsCert/",
|
||||
base_url
|
||||
);
|
||||
let poll_interval = Duration::from_secs(10);
|
||||
let max_attempts = args.csr_timeout / 10;
|
||||
let mut csr_pem = None;
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
print!(" Polling for CSR (attempt {}/{})... ", attempt, max_attempts);
|
||||
let discovery_resp = client
|
||||
.get(&cert_url)
|
||||
.header("X-Auth-Token", &token)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let resp = client
|
||||
.get(&cert_url)
|
||||
let discovery_body = discovery_resp.text().await?;
|
||||
let cert_data: HttpsCertResponse = serde_json::from_str(&discovery_body)
|
||||
.map_err(|e| format!("Failed to parse HttpsCert endpoint: {}", e))?;
|
||||
|
||||
let action_urls = cert_data
|
||||
.actions
|
||||
.as_ref()
|
||||
.and_then(|a| discover_action_urls(a, &base_url))
|
||||
.ok_or("Could not discover GenerateCSR/ImportCertificate action URLs")?;
|
||||
|
||||
println!(
|
||||
"Discovered actions: CSR={}, Import={}",
|
||||
action_urls.generate_csr, action_urls.import_cert
|
||||
);
|
||||
|
||||
// Check if a valid CSR is already pending with the correct CN
|
||||
let mut csr_pem = cert_data
|
||||
.certificate_signing_request
|
||||
.filter(|csr| csr.contains("BEGIN CERTIFICATE REQUEST"))
|
||||
.filter(|csr| csr_has_cn(csr, &cn));
|
||||
|
||||
if csr_pem.is_some() {
|
||||
println!("Found existing pending CSR with matching CN, reusing it");
|
||||
} else {
|
||||
// Step 3: Generate a CSR
|
||||
println!("Generating CSR for CN={}...", cn);
|
||||
|
||||
let csr_resp = client
|
||||
.post(&action_urls.generate_csr)
|
||||
.header("X-Auth-Token", &token)
|
||||
.json(&CsrRequest {
|
||||
action: "GenerateCSR".to_string(),
|
||||
country: args.country,
|
||||
state: args.state,
|
||||
city: args.city,
|
||||
org_name: args.org,
|
||||
org_unit: args.org_unit,
|
||||
common_name: cn.clone(),
|
||||
include_ip: args.include_ip,
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body = resp.text().await?;
|
||||
// The CSR field may be absent, null, or a PEM string
|
||||
if let Ok(cert_data) = serde_json::from_str::<HttpsCertResponse>(&body) {
|
||||
if let Some(ref csr) = cert_data.certificate_signing_request {
|
||||
if csr.contains("BEGIN CERTIFICATE REQUEST") {
|
||||
println!("ready");
|
||||
csr_pem = Some(csr.clone());
|
||||
break;
|
||||
if !csr_resp.status().is_success() {
|
||||
let body = csr_resp.text().await.unwrap_or_default();
|
||||
return Err(format!("CSR generation request failed: {}", body).into());
|
||||
}
|
||||
|
||||
// Step 4: Poll for CSR
|
||||
let poll_interval = Duration::from_secs(10);
|
||||
let max_attempts = args.csr_timeout / 10;
|
||||
|
||||
for attempt in 1..=max_attempts {
|
||||
tokio::time::sleep(poll_interval).await;
|
||||
print!(" Polling for CSR (attempt {}/{})... ", attempt, max_attempts);
|
||||
|
||||
let resp = client
|
||||
.get(&cert_url)
|
||||
.header("X-Auth-Token", &token)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let body = resp.text().await?;
|
||||
if let Ok(data) = serde_json::from_str::<HttpsCertResponse>(&body) {
|
||||
if let Some(ref csr) = data.certificate_signing_request {
|
||||
if csr.contains("BEGIN CERTIFICATE REQUEST") {
|
||||
println!("ready");
|
||||
csr_pem = Some(csr.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("not ready");
|
||||
}
|
||||
println!("not ready");
|
||||
}
|
||||
|
||||
let csr_pem = csr_pem.ok_or("Timed out waiting for CSR generation")?;
|
||||
|
||||
// Write CSR to temp file
|
||||
let csr_path = std::env::temp_dir().join(format!("{}.csr", cn));
|
||||
let cert_path = std::env::temp_dir().join(format!("{}.crt", cn));
|
||||
std::fs::write(&csr_path, &csr_pem)?;
|
||||
|
||||
println!("CSR saved to {}", csr_path.display());
|
||||
|
||||
// Step 4: Sign with step-ca
|
||||
// Step 5: Sign with step-ca natively
|
||||
let password_file = if args.provisioner_password_file.starts_with("~/") {
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
format!("{}/{}", home, &args.provisioner_password_file[2..])
|
||||
} else {
|
||||
args.provisioner_password_file.clone()
|
||||
};
|
||||
let password = std::fs::read_to_string(&password_file)
|
||||
.map_err(|e| format!("Failed to read provisioner password: {}", e))?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
println!("Signing CSR with step-ca ({})...", args.ca_url);
|
||||
|
||||
let sign_output = Command::new("step")
|
||||
.args([
|
||||
"ca",
|
||||
"sign",
|
||||
"--provisioner",
|
||||
&args.provisioner,
|
||||
"--provisioner-password-file",
|
||||
&password_file,
|
||||
"--ca-url",
|
||||
&args.ca_url,
|
||||
"--root",
|
||||
args.root_cert.to_str().unwrap(),
|
||||
csr_path.to_str().unwrap(),
|
||||
cert_path.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
// Fetch provisioner key from CA
|
||||
let (prov_info, encrypted_key) =
|
||||
step_ca::fetch_provisioner_key(&client, &args.ca_url, &args.provisioner).await?;
|
||||
println!(" Provisioner: {} (kid: {})", prov_info.name, prov_info.kid);
|
||||
|
||||
if !sign_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&sign_output.stderr);
|
||||
return Err(format!("step ca sign failed: {}", stderr).into());
|
||||
}
|
||||
// Decrypt the provisioner private key
|
||||
let private_key = step_ca::decrypt_provisioner_key(&encrypted_key, &password)?;
|
||||
|
||||
println!("Certificate signed: {}", cert_path.display());
|
||||
// Create the one-time token
|
||||
let ott = step_ca::create_sign_token(&private_key, &prov_info, &args.ca_url, &csr_pem)?;
|
||||
|
||||
// Step 5: Import cert (leaf + intermediate) to iLO
|
||||
let leaf_pem = std::fs::read_to_string(&cert_path)?;
|
||||
// Sign via the CA API
|
||||
let leaf_pem = step_ca::sign_csr(&client, &args.ca_url, &csr_pem, &ott).await?;
|
||||
|
||||
println!("Certificate signed");
|
||||
|
||||
// Build full chain (leaf + intermediate)
|
||||
let intermediate_pem = std::fs::read_to_string(&args.intermediate_cert)?;
|
||||
let full_chain = format!("{}{}", leaf_pem, intermediate_pem);
|
||||
|
||||
let import_url = format!(
|
||||
"{}/redfish/v1/Managers/1/SecurityService/HttpsCert/Actions/HpHttpsCert.ImportCertificate/",
|
||||
base_url
|
||||
);
|
||||
println!("Importing certificate to iLO...");
|
||||
|
||||
let import_resp = client
|
||||
.post(&import_url)
|
||||
.post(&action_urls.import_cert)
|
||||
.header("X-Auth-Token", &token)
|
||||
.json(&ImportCertRequest {
|
||||
action: "ImportCertificate".to_string(),
|
||||
@@ -315,50 +371,31 @@ pub async fn run(args: Args) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Import response: {}", import_body);
|
||||
}
|
||||
|
||||
// Step 6: Wait for reset and verify
|
||||
println!(
|
||||
"Waiting {}s for iLO to restart...",
|
||||
args.reset_wait
|
||||
);
|
||||
// Step 7: Wait for reset and verify
|
||||
println!("Waiting {}s for iLO to restart...", args.reset_wait);
|
||||
tokio::time::sleep(Duration::from_secs(args.reset_wait)).await;
|
||||
|
||||
// Verify the new cert
|
||||
// Verify the new cert by connecting with openssl
|
||||
println!("Verifying new certificate...");
|
||||
let verify_output = Command::new("openssl")
|
||||
.args([
|
||||
"s_client",
|
||||
"-connect",
|
||||
&format!(
|
||||
"{}:443",
|
||||
args.host
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
),
|
||||
"-noservername",
|
||||
])
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.output();
|
||||
let host_addr = args
|
||||
.host
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
|
||||
match verify_output {
|
||||
Ok(output) => {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if stdout.contains(&cn) {
|
||||
println!("Verified: iLO is now presenting cert for {}", cn);
|
||||
} else {
|
||||
println!("Warning: could not verify new cert (iLO may still be restarting)");
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
println!("Warning: could not connect to verify (iLO may still be restarting)");
|
||||
}
|
||||
let verify_client = Client::builder()
|
||||
.danger_accept_invalid_certs(true)
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()?;
|
||||
|
||||
match verify_client
|
||||
.get(format!("https://{}:{}", host_addr, 443))
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(_) => println!("Verified: iLO is back online with new cert for {}", cn),
|
||||
Err(_) => println!("Warning: could not reach iLO (may still be restarting)"),
|
||||
}
|
||||
|
||||
// Clean up temp files
|
||||
std::fs::remove_file(&csr_path).ok();
|
||||
std::fs::remove_file(&cert_path).ok();
|
||||
|
||||
println!("Done.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
pub mod ilo_cert;
|
||||
pub mod step_ca;
|
||||
|
||||
409
bin/cichlid-cli/src/commands/step_ca.rs
Normal file
409
bin/cichlid-cli/src/commands/step_ca.rs
Normal file
@@ -0,0 +1,409 @@
|
||||
//! Native step-ca CSR signing via the /1.0/sign API.
|
||||
//!
|
||||
//! Replaces the `step ca sign` CLI shell-out with direct HTTP calls:
|
||||
//! 1. Fetch provisioner config (encrypted JWK key) from /1.0/provisioners
|
||||
//! 2. Decrypt the JWE-encrypted private key with the provisioner password
|
||||
//! 3. Create an ES256 JWT (one-time token) with CSR claims
|
||||
//! 4. POST {csr, ott} to /1.0/sign
|
||||
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||
use openssl::bn::BigNum;
|
||||
use openssl::ec::{EcGroup, EcKey};
|
||||
use openssl::ecdsa::EcdsaSig;
|
||||
use openssl::hash::MessageDigest;
|
||||
use openssl::nid::Nid;
|
||||
use openssl::pkey::PKey;
|
||||
use openssl::sign::Signer;
|
||||
use openssl::symm::Cipher;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Fetch the encrypted provisioner key from the CA.
|
||||
pub async fn fetch_provisioner_key(
|
||||
client: &Client,
|
||||
ca_url: &str,
|
||||
provisioner_name: &str,
|
||||
) -> Result<(ProvisionerInfo, String), Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/1.0/provisioners", ca_url);
|
||||
let resp: ProvisionersResponse = client.get(&url).send().await?.json().await?;
|
||||
|
||||
let prov = resp
|
||||
.provisioners
|
||||
.iter()
|
||||
.find(|p| p.name == provisioner_name)
|
||||
.ok_or_else(|| format!("Provisioner '{}' not found", provisioner_name))?;
|
||||
|
||||
let encrypted_key = prov
|
||||
.encrypted_key
|
||||
.as_ref()
|
||||
.ok_or("Provisioner has no encrypted key")?
|
||||
.clone();
|
||||
|
||||
let kid = prov
|
||||
.key
|
||||
.as_ref()
|
||||
.ok_or("Provisioner has no public key")?
|
||||
.kid
|
||||
.clone();
|
||||
|
||||
let info = ProvisionerInfo {
|
||||
name: prov.name.clone(),
|
||||
kid,
|
||||
};
|
||||
|
||||
Ok((info, encrypted_key))
|
||||
}
|
||||
|
||||
/// Decrypt a JWE-encrypted provisioner key using PBES2-HS256+A128KW + A256GCM.
|
||||
pub fn decrypt_provisioner_key(
|
||||
jwe: &str,
|
||||
password: &str,
|
||||
) -> Result<EcPrivateKey, Box<dyn std::error::Error>> {
|
||||
let parts: Vec<&str> = jwe.split('.').collect();
|
||||
if parts.len() != 5 {
|
||||
return Err(format!("Invalid JWE: expected 5 parts, got {}", parts.len()).into());
|
||||
}
|
||||
|
||||
let header_b64 = parts[0];
|
||||
let encrypted_key_b64 = parts[1];
|
||||
let iv_b64 = parts[2];
|
||||
let ciphertext_b64 = parts[3];
|
||||
let tag_b64 = parts[4];
|
||||
|
||||
// Parse header
|
||||
let header_json = URL_SAFE_NO_PAD.decode(header_b64)?;
|
||||
let header: JweHeader = serde_json::from_slice(&header_json)?;
|
||||
|
||||
if header.alg != "PBES2-HS256+A128KW" {
|
||||
return Err(format!("Unsupported JWE alg: {}", header.alg).into());
|
||||
}
|
||||
if header.enc != "A256GCM" {
|
||||
return Err(format!("Unsupported JWE enc: {}", header.enc).into());
|
||||
}
|
||||
|
||||
let p2s = URL_SAFE_NO_PAD.decode(&header.p2s)?;
|
||||
let p2c = header.p2c;
|
||||
|
||||
// Derive the key-encryption key (KEK) via PBKDF2
|
||||
// PBES2-HS256+A128KW uses SHA-256 and produces a 16-byte key
|
||||
let salt_input = [b"PBES2-HS256+A128KW\0" as &[u8], &p2s].concat();
|
||||
let mut kek = vec![0u8; 16];
|
||||
openssl::pkcs5::pbkdf2_hmac(
|
||||
password.as_bytes(),
|
||||
&salt_input,
|
||||
p2c as usize,
|
||||
MessageDigest::sha256(),
|
||||
&mut kek,
|
||||
)?;
|
||||
|
||||
// Unwrap the content encryption key (CEK) using AES-128-KW (Key Wrap)
|
||||
let wrapped_cek = URL_SAFE_NO_PAD.decode(encrypted_key_b64)?;
|
||||
let cek = aes_key_unwrap(&kek, &wrapped_cek)?;
|
||||
|
||||
// Decrypt the content using AES-256-GCM
|
||||
let iv = URL_SAFE_NO_PAD.decode(iv_b64)?;
|
||||
let ciphertext = URL_SAFE_NO_PAD.decode(ciphertext_b64)?;
|
||||
let tag = URL_SAFE_NO_PAD.decode(tag_b64)?;
|
||||
|
||||
// AAD is the JWE protected header (base64url-encoded)
|
||||
let aad = header_b64.as_bytes();
|
||||
|
||||
let mut crypter = openssl::symm::Crypter::new(
|
||||
Cipher::aes_256_gcm(),
|
||||
openssl::symm::Mode::Decrypt,
|
||||
&cek,
|
||||
Some(&iv),
|
||||
)?;
|
||||
crypter.aad_update(aad)?;
|
||||
let mut plaintext = vec![0u8; ciphertext.len() + 16];
|
||||
let mut count = crypter.update(&ciphertext, &mut plaintext)?;
|
||||
crypter.set_tag(&tag)?;
|
||||
count += crypter.finalize(&mut plaintext[count..])?;
|
||||
plaintext.truncate(count);
|
||||
|
||||
let jwk: EcPrivateKey = serde_json::from_slice(&plaintext)?;
|
||||
Ok(jwk)
|
||||
}
|
||||
|
||||
/// Create a signed JWT (one-time token) for the /1.0/sign endpoint.
|
||||
pub fn create_sign_token(
|
||||
private_key: &EcPrivateKey,
|
||||
provisioner: &ProvisionerInfo,
|
||||
ca_url: &str,
|
||||
csr_pem: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Parse the CSR to extract CN and SANs
|
||||
let csr = openssl::x509::X509Req::from_pem(csr_pem.as_bytes())?;
|
||||
let subject = csr.subject_name();
|
||||
let cn = subject
|
||||
.entries_by_nid(Nid::COMMONNAME)
|
||||
.next()
|
||||
.and_then(|e| e.data().as_utf8().ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Extract SANs from CSR extensions
|
||||
let sans = extract_csr_sans(&csr);
|
||||
|
||||
// SHA256 fingerprint of the CSR DER
|
||||
let csr_der = csr.to_der()?;
|
||||
let sha = openssl::hash::hash(MessageDigest::sha256(), &csr_der)?;
|
||||
let sha_hex = sha
|
||||
.iter()
|
||||
.map(|b| format!("{:02x}", b))
|
||||
.collect::<String>();
|
||||
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_secs();
|
||||
|
||||
let header = serde_json::json!({
|
||||
"alg": "ES256",
|
||||
"typ": "JWT",
|
||||
"kid": provisioner.kid,
|
||||
});
|
||||
|
||||
let claims = serde_json::json!({
|
||||
"sub": cn,
|
||||
"sans": sans,
|
||||
"sha": sha_hex,
|
||||
"iss": provisioner.name,
|
||||
"aud": format!("{}/1.0/sign", ca_url),
|
||||
"exp": now + 300,
|
||||
"iat": now,
|
||||
"nbf": now,
|
||||
"jti": uuid::Uuid::new_v4().to_string(),
|
||||
});
|
||||
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?);
|
||||
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?);
|
||||
let signing_input = format!("{}.{}", header_b64, claims_b64);
|
||||
|
||||
// Build EC key from JWK
|
||||
let group = EcGroup::from_curve_name(Nid::X9_62_PRIME256V1)?;
|
||||
let x = BigNum::from_slice(&URL_SAFE_NO_PAD.decode(&private_key.x)?)?;
|
||||
let y = BigNum::from_slice(&URL_SAFE_NO_PAD.decode(&private_key.y)?)?;
|
||||
let d = BigNum::from_slice(
|
||||
&URL_SAFE_NO_PAD.decode(private_key.d.as_ref().ok_or("Missing private key 'd'")?)?,
|
||||
)?;
|
||||
|
||||
let mut ctx = openssl::bn::BigNumContext::new()?;
|
||||
let mut pub_key = openssl::ec::EcPoint::new(&group)?;
|
||||
pub_key.set_affine_coordinates_gfp(&group, &x, &y, &mut ctx)?;
|
||||
|
||||
let ec_key = EcKey::from_private_components(&group, &d, &pub_key)?;
|
||||
let pkey = PKey::from_ec_key(ec_key)?;
|
||||
|
||||
// Sign with ES256 (ECDSA with SHA-256)
|
||||
let mut signer = Signer::new(MessageDigest::sha256(), &pkey)?;
|
||||
signer.update(signing_input.as_bytes())?;
|
||||
let der_sig = signer.sign_to_vec()?;
|
||||
|
||||
// Convert DER signature to JWS format (r || s, each 32 bytes)
|
||||
let ecdsa_sig = EcdsaSig::from_der(&der_sig)?;
|
||||
let r = ecdsa_sig.r().to_vec_padded(32)?;
|
||||
let s = ecdsa_sig.s().to_vec_padded(32)?;
|
||||
let mut jws_sig = Vec::with_capacity(64);
|
||||
jws_sig.extend_from_slice(&r);
|
||||
jws_sig.extend_from_slice(&s);
|
||||
|
||||
let sig_b64 = URL_SAFE_NO_PAD.encode(&jws_sig);
|
||||
Ok(format!("{}.{}", signing_input, sig_b64))
|
||||
}
|
||||
|
||||
/// Sign a CSR via the step-ca /1.0/sign API. Returns the signed certificate PEM.
|
||||
pub async fn sign_csr(
|
||||
client: &Client,
|
||||
ca_url: &str,
|
||||
csr_pem: &str,
|
||||
ott: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let url = format!("{}/1.0/sign", ca_url);
|
||||
|
||||
let resp = client
|
||||
.post(&url)
|
||||
.json(&SignRequest {
|
||||
csr: csr_pem.to_string(),
|
||||
ott: ott.to_string(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(format!("step-ca /1.0/sign failed: {}", body).into());
|
||||
}
|
||||
|
||||
let sign_resp: SignResponse = resp.json().await?;
|
||||
Ok(sign_resp.crt)
|
||||
}
|
||||
|
||||
fn extract_csr_sans(csr: &openssl::x509::X509Req) -> Vec<String> {
|
||||
let mut sans = Vec::new();
|
||||
|
||||
// Add CN
|
||||
if let Some(cn) = csr
|
||||
.subject_name()
|
||||
.entries_by_nid(Nid::COMMONNAME)
|
||||
.next()
|
||||
.and_then(|e| e.data().as_utf8().ok())
|
||||
{
|
||||
sans.push(cn.to_string());
|
||||
}
|
||||
|
||||
// Extract SANs by printing the CSR to text and parsing the output.
|
||||
// This is simpler than dealing with the low-level extension API.
|
||||
let csr_pem = match csr.to_pem() {
|
||||
Ok(pem) => pem,
|
||||
Err(_) => return sans,
|
||||
};
|
||||
let output = std::process::Command::new("openssl")
|
||||
.args(["req", "-noout", "-text"])
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
if let Some(ref mut stdin) = child.stdin {
|
||||
stdin.write_all(&csr_pem).ok();
|
||||
}
|
||||
child.wait_with_output()
|
||||
});
|
||||
|
||||
if let Ok(output) = output {
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
// Look for "DNS:" and "IP Address:" in the SAN line
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.contains("DNS:") || trimmed.contains("IP Address:") {
|
||||
for part in trimmed.split(',') {
|
||||
let part = part.trim();
|
||||
if let Some(dns) = part.strip_prefix("DNS:") {
|
||||
let dns = dns.trim().to_string();
|
||||
if !sans.contains(&dns) {
|
||||
sans.push(dns);
|
||||
}
|
||||
} else if let Some(ip) = part.strip_prefix("IP Address:") {
|
||||
let ip = ip.trim().to_string();
|
||||
if !sans.contains(&ip) {
|
||||
sans.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sans
|
||||
}
|
||||
|
||||
/// AES Key Unwrap (RFC 3394)
|
||||
fn aes_key_unwrap(kek: &[u8], wrapped: &[u8]) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
if wrapped.len() < 24 || wrapped.len() % 8 != 0 {
|
||||
return Err("Invalid wrapped key length".into());
|
||||
}
|
||||
|
||||
let n = (wrapped.len() / 8) - 1;
|
||||
let mut a = [0u8; 8];
|
||||
a.copy_from_slice(&wrapped[0..8]);
|
||||
|
||||
let mut r: Vec<[u8; 8]> = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let mut block = [0u8; 8];
|
||||
block.copy_from_slice(&wrapped[(i + 1) * 8..(i + 2) * 8]);
|
||||
r.push(block);
|
||||
}
|
||||
|
||||
let cipher = openssl::symm::Cipher::aes_128_ecb();
|
||||
|
||||
for j in (0..=5).rev() {
|
||||
for i in (0..n).rev() {
|
||||
let t = ((n * j) + i + 1) as u64;
|
||||
let t_bytes = t.to_be_bytes();
|
||||
let mut a_xor = [0u8; 8];
|
||||
for k in 0..8 {
|
||||
a_xor[k] = a[k] ^ t_bytes[k];
|
||||
}
|
||||
|
||||
let mut input = [0u8; 16];
|
||||
input[0..8].copy_from_slice(&a_xor);
|
||||
input[8..16].copy_from_slice(&r[i]);
|
||||
|
||||
// Use a Crypter to disable padding (RFC 3394 operates on exact blocks)
|
||||
let mut crypter =
|
||||
openssl::symm::Crypter::new(cipher, openssl::symm::Mode::Decrypt, kek, None)?;
|
||||
crypter.pad(false);
|
||||
let mut decrypted = vec![0u8; 32];
|
||||
let count = crypter.update(&input, &mut decrypted)?;
|
||||
let rest = crypter.finalize(&mut decrypted[count..])?;
|
||||
let total = count + rest;
|
||||
|
||||
a.copy_from_slice(&decrypted[0..8]);
|
||||
r[i].copy_from_slice(&decrypted[8..std::cmp::min(16, total + 8)]);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify IV
|
||||
let default_iv: [u8; 8] = [0xA6; 8];
|
||||
if a != default_iv {
|
||||
return Err("AES Key Unwrap: integrity check failed (wrong password?)".into());
|
||||
}
|
||||
|
||||
let mut result = Vec::with_capacity(n * 8);
|
||||
for block in r {
|
||||
result.extend_from_slice(&block);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// --- Types ---
|
||||
|
||||
pub struct ProvisionerInfo {
|
||||
pub name: String,
|
||||
pub kid: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProvisionersResponse {
|
||||
provisioners: Vec<Provisioner>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Provisioner {
|
||||
name: String,
|
||||
key: Option<ProvisionerPublicKey>,
|
||||
#[serde(rename = "encryptedKey")]
|
||||
encrypted_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProvisionerPublicKey {
|
||||
kid: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct JweHeader {
|
||||
alg: String,
|
||||
enc: String,
|
||||
p2c: u64,
|
||||
p2s: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EcPrivateKey {
|
||||
pub x: String,
|
||||
pub y: String,
|
||||
pub d: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SignRequest {
|
||||
csr: String,
|
||||
ott: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SignResponse {
|
||||
crt: String,
|
||||
}
|
||||
Reference in New Issue
Block a user