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)

//! Security Profile enforcement at connection time (OCPP 2.0.1/2.1 Part 2 §A00,
//! identical to the OCPP 1.6 security whitepaper profiles).
//!
//! | Profile | Transport | Station authentication |
//! |---|---|---|
//! | 0 (none configured) | any | password if one is set (network-level security) |
//! | 1 | plain only | HTTP Basic |
//! | 2 | TLS | HTTP Basic |
//! | 3 | TLS | client certificate: valid chain, O = CSO name, CN = serial number |
//!
//! A00.FR.002: a station connecting with a different profile than configured is refused.

use crate::tls::TlsInfo;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Denied {
    /// The station uses another profile than the one configured for it.
    ProfileMismatch(&'static str),
    /// Missing or wrong Basic credentials.
    BadCredentials,
    /// Profile 3: missing, invalid or foreign client certificate.
    InvalidCertificate(String),
}

pub struct Inputs<'a> {
    pub profile: Option<i16>,
    pub tls: Option<&'a TlsInfo>,
    /// `None`: no Authorization header; `Some(ok)`: credentials checked.
    pub basic: Option<bool>,
    pub has_password: bool,
    pub require_password: bool,
    pub identity: &'a str,
    pub serial_number: Option<&'a str>,
    pub cso_name: Option<&'a str>,
}

pub fn check(i: &Inputs<'_>) -> Result<(), Denied> {
    let basic_ok = || match i.basic {
        Some(true) => Ok(()),
        _ => Err(Denied::BadCredentials),
    };
    match i.profile {
        None | Some(0) => {
            if i.has_password || i.require_password {
                basic_ok()
            } else {
                Ok(())
            }
        }
        Some(1) => {
            if i.tls.is_some() {
                return Err(Denied::ProfileMismatch(
                    "station is configured for Security Profile 1 (unsecured transport)",
                ));
            }
            basic_ok()
        }
        Some(2) => {
            if i.tls.is_none() {
                return Err(Denied::ProfileMismatch(
                    "station is configured for Security Profile 2 (TLS required)",
                ));
            }
            basic_ok()
        }
        Some(3) => {
            let Some(tls) = i.tls else {
                return Err(Denied::ProfileMismatch(
                    "station is configured for Security Profile 3 (TLS with client certificate required)",
                ));
            };
            let Some(cert) = &tls.client_cert else {
                return Err(Denied::InvalidCertificate(
                    "no client certificate presented".into(),
                ));
            };
            if !cert.verified {
                return Err(Denied::InvalidCertificate(
                    cert.error
                        .clone()
                        .unwrap_or_else(|| "certificate not verified".into()),
                ));
            }
            // A00.FR.404: owned by the CSO.
            if let Some(cso) = i.cso_name
                && !cert
                    .organization
                    .as_deref()
                    .is_some_and(|o| o.contains(cso))
            {
                return Err(Denied::InvalidCertificate(format!(
                    "organizationName {:?} does not contain the CSO name",
                    cert.organization
                )));
            }
            // A00.FR.405: CN contains the station's serial number. The OCPP identity is
            // accepted too, as deployments commonly use the serial number as identity.
            let cn = cert.common_name.as_deref().unwrap_or("");
            let matches = (!cn.is_empty() && cn.contains(i.identity))
                || i.serial_number
                    .is_some_and(|s| !s.is_empty() && cn.contains(s));
            if !matches {
                return Err(Denied::InvalidCertificate(format!(
                    "commonName {cn:?} does not match the charging station"
                )));
            }
            Ok(())
        }
        Some(_) => Err(Denied::ProfileMismatch(
            "invalid security profile configured",
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tls::ClientCert;

    fn tls(cert: Option<(&str, &str, bool)>) -> TlsInfo {
        TlsInfo {
            version: "TLSv1.3".into(),
            cipher: "x".into(),
            client_cert: cert.map(|(o, cn, verified)| ClientCert {
                common_name: Some(cn.into()),
                organization: Some(o.into()),
                verified,
                error: (!verified).then(|| "unable to get local issuer certificate".into()),
                serial_hex: "01".into(),
            }),
        }
    }

    fn inputs<'a>(profile: Option<i16>, t: Option<&'a TlsInfo>, basic: Option<bool>) -> Inputs<'a> {
        Inputs {
            profile,
            tls: t,
            basic,
            has_password: true,
            require_password: false,
            identity: "CS1",
            serial_number: Some("SN-9"),
            cso_name: Some("TestCSO"),
        }
    }

    #[test]
    fn profiles() {
        let plain = None;
        let t = tls(None);
        let good = tls(Some(("TestCSO", "CS1", true)));
        let by_serial = tls(Some(("TestCSO", "SN-9", true)));
        let other_org = tls(Some(("OtherCSO", "CS1", true)));
        let untrusted = tls(Some(("TestCSO", "CS1", false)));
        let wrong_cn = tls(Some(("TestCSO", "CS2", true)));

        assert_eq!(check(&inputs(Some(1), plain, Some(true))), Ok(()));
        assert_eq!(
            check(&inputs(Some(1), plain, None)),
            Err(Denied::BadCredentials)
        );
        assert!(matches!(
            check(&inputs(Some(1), Some(&t), Some(true))),
            Err(Denied::ProfileMismatch(_))
        ));

        assert_eq!(check(&inputs(Some(2), Some(&t), Some(true))), Ok(()));
        assert_eq!(
            check(&inputs(Some(2), Some(&t), Some(false))),
            Err(Denied::BadCredentials)
        );
        assert!(matches!(
            check(&inputs(Some(2), plain, Some(true))),
            Err(Denied::ProfileMismatch(_))
        ));

        assert_eq!(check(&inputs(Some(3), Some(&good), None)), Ok(()));
        assert_eq!(check(&inputs(Some(3), Some(&by_serial), None)), Ok(()));
        for bad in [&t, &other_org, &untrusted, &wrong_cn] {
            assert!(matches!(
                check(&inputs(Some(3), Some(bad), None)),
                Err(Denied::InvalidCertificate(_))
            ));
        }
        assert!(matches!(
            check(&inputs(Some(3), plain, None)),
            Err(Denied::ProfileMismatch(_))
        ));

        let mut open = inputs(None, plain, None);
        open.has_password = false;
        assert_eq!(check(&open), Ok(()));
        open.require_password = true;
        assert_eq!(check(&open), Err(Denied::BadCredentials));
    }
}