use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::{self, Cursor, ErrorKind, Write};
use std::path::{Path, PathBuf};
use std::str::FromStr;
use rcgen::{CertificateParams, KeyPair, PublicKeyData};
use rustls_pemfile::certs;
use sha2::{Digest, Sha256};
use time::{Duration, OffsetDateTime};
use x509_parser::parse_x509_certificate;
#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;
pub const CLIENT_CERT_LIFETIME_DAYS: u64 = 90;
pub const CLIENT_CERT_RENEW_AFTER_DAYS: u64 = 60;
pub const CLIENT_CERT_FILENAME: &str = "client.crt";
pub const CLIENT_KEY_FILENAME: &str = "client.key";
pub const CLIENT_IDENTITY_FILENAME: &str = "client-identity.txt";
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub struct ClientIdentity([u8; 32]);
impl ClientIdentity {
pub fn from_subject_public_key_info(subject_public_key_info: &[u8]) -> Self {
let fingerprint = Sha256::digest(subject_public_key_info);
let mut bytes = [0_u8; 32];
bytes.copy_from_slice(&fingerprint);
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl fmt::Display for ClientIdentity {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for byte in self.0 {
write!(formatter, "{byte:02x}")?;
}
Ok(())
}
}
impl FromStr for ClientIdentity {
type Err = ParseClientIdentityError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
if value.len() != 64 {
return Err(ParseClientIdentityError::WrongLength);
}
let mut bytes = [0_u8; 32];
for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
let pair =
std::str::from_utf8(chunk).map_err(|_| ParseClientIdentityError::InvalidHex)?;
if pair
.chars()
.any(|character| !character.is_ascii_hexdigit() || character.is_ascii_uppercase())
{
return Err(ParseClientIdentityError::InvalidHex);
}
bytes[index] =
u8::from_str_radix(pair, 16).map_err(|_| ParseClientIdentityError::InvalidHex)?;
}
Ok(Self(bytes))
}
}
#[derive(Debug)]
pub enum ParseClientIdentityError {
WrongLength,
InvalidHex,
}
impl fmt::Display for ParseClientIdentityError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::WrongLength => write!(
formatter,
"client identity must be 64 lowercase hex characters"
),
Self::InvalidHex => write!(
formatter,
"client identity must use lowercase hex without separators"
),
}
}
}
impl std::error::Error for ParseClientIdentityError {}
pub struct GeneratedClientIdentity {
pub private_key_pem: String,
pub certificate_pem: String,
pub client_identity: ClientIdentity,
}
pub struct ClientCertificateState {
pub client_identity: ClientIdentity,
pub renew_at: OffsetDateTime,
pub expires_at: OffsetDateTime,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ClientCertificateRenewalDecision {
NotDue {
renew_at: OffsetDateTime,
expires_at: OffsetDateTime,
},
Due {
renew_at: OffsetDateTime,
expires_at: OffsetDateTime,
},
Expired {
expired_at: OffsetDateTime,
},
}
#[derive(Debug)]
pub enum ParseClientIdentityCertificateError {
ParseCertificate,
}
impl fmt::Display for ParseClientIdentityCertificateError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ParseCertificate => {
formatter.write_str("client certificate is not valid X.509 DER")
}
}
}
}
impl std::error::Error for ParseClientIdentityCertificateError {}
pub fn generate_client_identity() -> Result<GeneratedClientIdentity, rcgen::Error> {
let signing_key = KeyPair::generate()?;
let certificate_pem = issue_client_certificate(&signing_key, OffsetDateTime::now_utc())?;
let client_identity =
ClientIdentity::from_subject_public_key_info(&signing_key.subject_public_key_info());
Ok(GeneratedClientIdentity {
private_key_pem: signing_key.serialize_pem(),
certificate_pem,
client_identity,
})
}
#[derive(Debug)]
pub enum ClientIdentityMaterialError {
ReadFile {
path: PathBuf,
source: io::Error,
},
WriteFile {
path: PathBuf,
source: io::Error,
},
ParseCertificate {
path: PathBuf,
},
ParseKey(rcgen::Error),
Generate(rcgen::Error),
IdentityMismatch {
stored: ClientIdentity,
derived: ClientIdentity,
},
}
impl fmt::Display for ClientIdentityMaterialError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ReadFile { path, .. } => {
write!(formatter, "failed to read {}", path.display())
}
Self::WriteFile { path, .. } => {
write!(formatter, "failed to write {}", path.display())
}
Self::ParseCertificate { path } => {
write!(
formatter,
"failed to parse a client certificate from {}",
path.display()
)
}
Self::ParseKey(_) => formatter.write_str("failed to parse the client private key"),
Self::Generate(_) => formatter.write_str("failed to generate a client certificate"),
Self::IdentityMismatch { stored, derived } => write!(
formatter,
"stored client identity {stored} does not match the current key or certificate identity {derived}"
),
}
}
}
impl std::error::Error for ClientIdentityMaterialError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::ReadFile { source, .. } | Self::WriteFile { source, .. } => Some(source),
Self::ParseKey(source) | Self::Generate(source) => Some(source),
Self::ParseCertificate { .. } | Self::IdentityMismatch { .. } => None,
}
}
}
pub fn renew_client_identity_certificate(
directory: &Path,
) -> Result<ClientCertificateState, ClientIdentityMaterialError> {
let material = load_client_identity_material(directory)?;
let certificate_pem =
issue_client_certificate(&material.signing_key, OffsetDateTime::now_utc())
.map_err(ClientIdentityMaterialError::Generate)?;
let certificate_path = directory.join(CLIENT_CERT_FILENAME);
replace_file_atomically_with_mode(&certificate_path, certificate_pem.as_bytes(), 0o644)?;
let updated_material = load_client_identity_material(directory)?;
Ok(ClientCertificateState {
client_identity: updated_material.client_identity,
renew_at: updated_material.renew_at,
expires_at: updated_material.expires_at,
})
}
pub fn inspect_client_certificate_renewal(
directory: &Path,
now: OffsetDateTime,
) -> Result<ClientCertificateRenewalDecision, ClientIdentityMaterialError> {
let material = load_client_identity_material(directory)?;
Ok(decide_client_certificate_renewal(
material.renew_at,
material.expires_at,
now,
))
}
pub fn read_client_identity(
directory: &Path,
) -> Result<ClientIdentity, ClientIdentityMaterialError> {
Ok(load_client_identity_material(directory)?.client_identity)
}
pub fn rotate_client_identity(
directory: &Path,
) -> Result<ClientCertificateState, ClientIdentityMaterialError> {
let _ = load_client_identity_material(directory)?;
let generated = generate_client_identity().map_err(ClientIdentityMaterialError::Generate)?;
replace_file_atomically_with_mode(
&directory.join(CLIENT_KEY_FILENAME),
generated.private_key_pem.as_bytes(),
0o600,
)?;
replace_file_atomically_with_mode(
&directory.join(CLIENT_CERT_FILENAME),
generated.certificate_pem.as_bytes(),
0o644,
)?;
replace_file_atomically_with_mode(
&directory.join(CLIENT_IDENTITY_FILENAME),
generated.client_identity.to_string().as_bytes(),
0o644,
)?;
let updated_material = load_client_identity_material(directory)?;
Ok(ClientCertificateState {
client_identity: updated_material.client_identity,
renew_at: updated_material.renew_at,
expires_at: updated_material.expires_at,
})
}
pub fn client_identity_from_certificate_der(
certificate_der: &[u8],
) -> Result<ClientIdentity, ParseClientIdentityCertificateError> {
let (remainder, certificate) = parse_x509_certificate(certificate_der)
.map_err(|_| ParseClientIdentityCertificateError::ParseCertificate)?;
if !remainder.is_empty() {
return Err(ParseClientIdentityCertificateError::ParseCertificate);
}
Ok(ClientIdentity::from_subject_public_key_info(
certificate.tbs_certificate.subject_pki.raw,
))
}
fn issue_client_certificate(
signing_key: &KeyPair,
now: OffsetDateTime,
) -> Result<String, rcgen::Error> {
let not_before = now - Duration::minutes(1);
let mut certificate_params = CertificateParams::new(vec!["runewarp-client".to_owned()])?;
certificate_params.not_before = not_before;
certificate_params.not_after = not_before + Duration::days(CLIENT_CERT_LIFETIME_DAYS as i64);
let certificate = certificate_params.self_signed(signing_key)?;
Ok(certificate.pem())
}
struct LoadedClientIdentityMaterial {
signing_key: KeyPair,
client_identity: ClientIdentity,
renew_at: OffsetDateTime,
expires_at: OffsetDateTime,
}
fn load_client_identity_material(
directory: &Path,
) -> Result<LoadedClientIdentityMaterial, ClientIdentityMaterialError> {
let key_path = directory.join(CLIENT_KEY_FILENAME);
let key_pem =
fs::read_to_string(&key_path).map_err(|source| ClientIdentityMaterialError::ReadFile {
path: key_path.clone(),
source,
})?;
let signing_key = KeyPair::from_pem(&key_pem).map_err(ClientIdentityMaterialError::ParseKey)?;
let key_identity =
ClientIdentity::from_subject_public_key_info(&signing_key.subject_public_key_info());
let identity_path = directory.join(CLIENT_IDENTITY_FILENAME);
let stored_identity = fs::read_to_string(&identity_path)
.map_err(|source| ClientIdentityMaterialError::ReadFile {
path: identity_path.clone(),
source,
})?
.trim()
.parse::<ClientIdentity>()
.map_err(|_| ClientIdentityMaterialError::ParseCertificate {
path: identity_path,
})?;
if stored_identity != key_identity {
return Err(ClientIdentityMaterialError::IdentityMismatch {
stored: stored_identity,
derived: key_identity,
});
}
let certificate_path = directory.join(CLIENT_CERT_FILENAME);
let certificate_pem =
fs::read(&certificate_path).map_err(|source| ClientIdentityMaterialError::ReadFile {
path: certificate_path.clone(),
source,
})?;
let certificate_der = certs(&mut Cursor::new(certificate_pem))
.next()
.transpose()
.map_err(|source| ClientIdentityMaterialError::ReadFile {
path: certificate_path.clone(),
source,
})?
.ok_or_else(|| ClientIdentityMaterialError::ParseCertificate {
path: certificate_path.clone(),
})?;
let certificate_identity = client_identity_from_certificate_der(certificate_der.as_ref())
.map_err(|_| ClientIdentityMaterialError::ParseCertificate {
path: certificate_path.clone(),
})?;
if stored_identity != certificate_identity {
return Err(ClientIdentityMaterialError::IdentityMismatch {
stored: stored_identity,
derived: certificate_identity,
});
}
let (_, certificate) = parse_x509_certificate(certificate_der.as_ref()).map_err(|_| {
ClientIdentityMaterialError::ParseCertificate {
path: certificate_path.clone(),
}
})?;
let renew_at = certificate.validity().not_before.to_datetime()
+ Duration::days(CLIENT_CERT_RENEW_AFTER_DAYS as i64);
let expires_at = certificate.validity().not_after.to_datetime();
Ok(LoadedClientIdentityMaterial {
signing_key,
client_identity: stored_identity,
renew_at,
expires_at,
})
}
fn replace_file_atomically_with_mode(
path: &Path,
contents: &[u8],
mode: u32,
) -> Result<(), ClientIdentityMaterialError> {
let Some(parent) = path.parent() else {
return Err(ClientIdentityMaterialError::WriteFile {
path: path.to_path_buf(),
source: io::Error::new(ErrorKind::InvalidInput, "missing parent directory"),
});
};
let Some(filename) = path.file_name() else {
return Err(ClientIdentityMaterialError::WriteFile {
path: path.to_path_buf(),
source: io::Error::new(ErrorKind::InvalidInput, "missing filename"),
});
};
for attempt in 0..16 {
let temporary_path = parent.join(format!(
".{}.runewarp-tmp-{}-{attempt}",
filename.to_string_lossy(),
std::process::id()
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(mode);
let mut file = match options.open(&temporary_path) {
Ok(file) => file,
Err(source) if source.kind() == ErrorKind::AlreadyExists => continue,
Err(source) => {
return Err(ClientIdentityMaterialError::WriteFile {
path: path.to_path_buf(),
source,
});
}
};
if let Err(source) = file.write_all(contents) {
let _ = fs::remove_file(&temporary_path);
return Err(ClientIdentityMaterialError::WriteFile {
path: path.to_path_buf(),
source,
});
}
drop(file);
if let Err(source) = fs::rename(&temporary_path, path) {
let _ = fs::remove_file(&temporary_path);
return Err(ClientIdentityMaterialError::WriteFile {
path: path.to_path_buf(),
source,
});
}
return Ok(());
}
Err(ClientIdentityMaterialError::WriteFile {
path: path.to_path_buf(),
source: io::Error::new(
ErrorKind::AlreadyExists,
"failed to allocate a temporary file for atomic replacement",
),
})
}
pub fn decide_client_certificate_renewal(
renew_at: OffsetDateTime,
expires_at: OffsetDateTime,
now: OffsetDateTime,
) -> ClientCertificateRenewalDecision {
if now >= expires_at {
ClientCertificateRenewalDecision::Expired {
expired_at: expires_at,
}
} else if now >= renew_at {
ClientCertificateRenewalDecision::Due {
renew_at,
expires_at,
}
} else {
ClientCertificateRenewalDecision::NotDue {
renew_at,
expires_at,
}
}
}
#[cfg(test)]
mod tests {
use proptest::prelude::*;
use time::{Duration, OffsetDateTime};
use super::{
CLIENT_CERT_LIFETIME_DAYS, CLIENT_CERT_RENEW_AFTER_DAYS, ClientCertificateRenewalDecision,
ClientIdentity, decide_client_certificate_renewal,
};
#[test]
fn parses_lowercase_hex_client_identities() {
let identity = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
.parse::<ClientIdentity>()
.unwrap();
assert_eq!(
identity.to_string(),
"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
);
}
#[test]
fn rejects_non_lowercase_hex_client_identities() {
assert!(
"00112233445566778899AAbbccddeeff00112233445566778899aabbccddeeff"
.parse::<ClientIdentity>()
.is_err()
);
assert!(
"00:11:22:33:44:55:66:77:88:99:aa:bb:cc:dd:ee:ff0011223344556677"
.parse::<ClientIdentity>()
.is_err()
);
}
#[test]
fn phase_two_identity_defaults_stay_explicit() {
assert_eq!(CLIENT_CERT_LIFETIME_DAYS, 90);
assert_eq!(CLIENT_CERT_RENEW_AFTER_DAYS, 60);
}
#[test]
fn renewal_decision_is_not_due_before_the_renewal_window() {
let renew_at = OffsetDateTime::UNIX_EPOCH + Duration::days(60);
let expires_at = OffsetDateTime::UNIX_EPOCH + Duration::days(90);
assert_eq!(
decide_client_certificate_renewal(
renew_at,
expires_at,
OffsetDateTime::UNIX_EPOCH + Duration::days(59),
),
ClientCertificateRenewalDecision::NotDue {
renew_at,
expires_at,
}
);
}
#[test]
fn renewal_decision_is_due_inside_the_renewal_window() {
let renew_at = OffsetDateTime::UNIX_EPOCH + Duration::days(60);
let expires_at = OffsetDateTime::UNIX_EPOCH + Duration::days(90);
assert_eq!(
decide_client_certificate_renewal(
renew_at,
expires_at,
OffsetDateTime::UNIX_EPOCH + Duration::days(60),
),
ClientCertificateRenewalDecision::Due {
renew_at,
expires_at,
}
);
}
#[test]
fn renewal_decision_marks_expired_certificates() {
let renew_at = OffsetDateTime::UNIX_EPOCH + Duration::days(60);
let expires_at = OffsetDateTime::UNIX_EPOCH + Duration::days(90);
assert_eq!(
decide_client_certificate_renewal(
renew_at,
expires_at,
OffsetDateTime::UNIX_EPOCH + Duration::days(90),
),
ClientCertificateRenewalDecision::Expired {
expired_at: expires_at
}
);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(32))]
#[test]
fn client_identities_round_trip_through_lowercase_hex(
bytes in proptest::array::uniform32(any::<u8>()),
) {
let encoded = bytes
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>();
let parsed = encoded.parse::<ClientIdentity>().unwrap();
prop_assert_eq!(parsed.to_string(), encoded);
}
}
}