codetics-ocpp-service 0.1.0

Codetics OCPP: Charging Station Management System (CSMS) service for OCPP 1.6J, 2.0.1 and 2.1, with TLS security profiles, smart charging, reservations and tariffs
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)

//! TLS for the OCPP endpoint (OCPP 2.0.1/2.1 Part 2 §A00, OCPP 1.6 security whitepaper).
//!
//! - TLS 1.2 or higher only (A00.FR.313), no TLS compression (A00.FR.321), no renegotiation.
//! - Cipher suites: the four required by A00.FR.318 (ECDHE-ECDSA and plain RSA key
//!   exchange with AES-GCM) plus ECDHE-RSA. Serving both suite families requires
//!   an ECDSA and an RSA certificate; up to two certificate/key pairs are loaded.
//! - Client certificates (Security Profile 3) are requested but not required at the
//!   TLS level, so that profile 2 and profile 3 stations can share one port. The
//!   verification outcome is passed to the WebSocket handshake, which enforces the
//!   station's configured profile.
//! - Certificates are reloaded with [`TlsAcceptor::reload`] (wired to SIGHUP).

use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, RwLock};

use openssl::nid::Nid;
use openssl::ssl::{Ssl, SslAcceptor, SslMethod, SslOptions, SslVerifyMode, SslVersion};
use openssl::x509::{X509, X509NameRef, X509VerifyResult};
use tokio::net::TcpStream;
use tokio_openssl::SslStream;

/// Default TLS 1.2 cipher list: A00.FR.318 suites plus ECDHE-RSA (forward secrecy with RSA keys).
pub const DEFAULT_CIPHERS: &str = "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:\
ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:AES128-GCM-SHA256:AES256-GCM-SHA384";

#[derive(Debug, Clone)]
pub struct TlsSettings {
    /// One or two (certificate chain PEM, private key PEM) pairs, e.g. ECDSA and RSA.
    pub identities: Vec<(PathBuf, PathBuf)>,
    /// CA bundle (PEM) used to verify charging station certificates (Security Profile 3).
    pub client_ca: Option<PathBuf>,
    /// OpenSSL cipher list for TLS 1.2.
    pub ciphers: String,
}

/// Outcome of the TLS handshake, attached to the HTTP request.
#[derive(Debug, Clone)]
pub struct TlsInfo {
    pub version: String,
    pub cipher: String,
    pub client_cert: Option<ClientCert>,
}

#[derive(Debug, Clone)]
pub struct ClientCert {
    pub common_name: Option<String>,
    pub organization: Option<String>,
    /// Chain verified against the configured CA bundle (includes the validity period).
    pub verified: bool,
    /// OpenSSL verification error when not verified.
    pub error: Option<String>,
    pub serial_hex: String,
}

pub struct TlsAcceptor {
    settings: TlsSettings,
    current: RwLock<Arc<SslAcceptor>>,
}

fn build(s: &TlsSettings) -> Result<SslAcceptor, String> {
    if s.identities.is_empty() {
        return Err("at least one TLS certificate/key pair is required".into());
    }
    let mut b =
        SslAcceptor::mozilla_intermediate_v5(SslMethod::tls_server()).map_err(|e| e.to_string())?;
    b.set_min_proto_version(Some(SslVersion::TLS1_2))
        .map_err(|e| e.to_string())?;
    b.set_cipher_list(&s.ciphers)
        .map_err(|e| format!("invalid cipher list: {e}"))?;
    b.set_options(
        SslOptions::NO_COMPRESSION
            | SslOptions::NO_RENEGOTIATION
            | SslOptions::CIPHER_SERVER_PREFERENCE,
    );
    // OpenSSL keeps one certificate slot per key type, so an ECDSA and an RSA pair can coexist.
    for (cert, key) in &s.identities {
        b.set_certificate_chain_file(cert)
            .map_err(|e| format!("{}: {e}", cert.display()))?;
        b.set_private_key_file(key, openssl::ssl::SslFiletype::PEM)
            .map_err(|e| format!("{}: {e}", key.display()))?;
        b.check_private_key()
            .map_err(|e| format!("{} does not match {}: {e}", key.display(), cert.display()))?;
    }
    if let Some(ca) = &s.client_ca {
        b.set_ca_file(ca)
            .map_err(|e| format!("{}: {e}", ca.display()))?;
        let pem = std::fs::read(ca).map_err(|e| format!("{}: {e}", ca.display()))?;
        let mut names = openssl::stack::Stack::new().map_err(|e| e.to_string())?;
        for cert in X509::stack_from_pem(&pem).map_err(|e| e.to_string())? {
            names
                .push(cert.subject_name().to_owned().map_err(|e| e.to_string())?)
                .map_err(|e| e.to_string())?;
        }
        b.set_client_ca_list(names);
        // Request a certificate; the profile check happens after the handshake.
        b.set_verify_callback(SslVerifyMode::PEER, |_, _| true);
    }
    b.set_session_id_context(b"ocpp-csms")
        .map_err(|e| e.to_string())?;
    Ok(b.build())
}

fn name_entry(name: &X509NameRef, nid: Nid) -> Option<String> {
    name.entries_by_nid(nid)
        .next()
        .and_then(|e| e.data().to_string().ok())
}

impl TlsAcceptor {
    pub fn new(settings: TlsSettings) -> Result<TlsAcceptor, String> {
        let acceptor = build(&settings)?;
        Ok(TlsAcceptor {
            settings,
            current: RwLock::new(Arc::new(acceptor)),
        })
    }

    /// Re-reads certificates and keys from disk; keeps the old ones on error.
    pub fn reload(&self) -> Result<(), String> {
        let acceptor = build(&self.settings)?;
        *self.current.write().unwrap_or_else(|e| e.into_inner()) = Arc::new(acceptor);
        Ok(())
    }

    pub fn verifies_clients(&self) -> bool {
        self.settings.client_ca.is_some()
    }

    pub async fn accept(&self, tcp: TcpStream) -> Result<(SslStream<TcpStream>, TlsInfo), String> {
        let acceptor = self
            .current
            .read()
            .unwrap_or_else(|e| e.into_inner())
            .clone();
        let ssl = Ssl::new(acceptor.context()).map_err(|e| e.to_string())?;
        let mut stream = SslStream::new(ssl, tcp).map_err(|e| e.to_string())?;
        Pin::new(&mut stream)
            .accept()
            .await
            .map_err(|e| e.to_string())?;
        let ssl = stream.ssl();
        let client_cert = ssl.peer_certificate().map(|cert| {
            let result = ssl.verify_result();
            let verified = self.verifies_clients() && result == X509VerifyResult::OK;
            ClientCert {
                common_name: name_entry(cert.subject_name(), Nid::COMMONNAME),
                organization: name_entry(cert.subject_name(), Nid::ORGANIZATIONNAME),
                verified,
                error: (!verified).then(|| {
                    if self.verifies_clients() {
                        result.error_string().to_owned()
                    } else {
                        "client certificates are not configured".into()
                    }
                }),
                serial_hex: cert
                    .serial_number()
                    .to_bn()
                    .and_then(|b| b.to_hex_str())
                    .map(|s| s.to_string())
                    .unwrap_or_default(),
            }
        });
        let info = TlsInfo {
            version: ssl.version_str().to_owned(),
            cipher: ssl
                .current_cipher()
                .map(|c| c.name().to_owned())
                .unwrap_or_default(),
            client_cert,
        };
        Ok((stream, info))
    }
}