Skip to main content

ocpp_service/ocpp/
security.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! Security Profile enforcement at connection time (OCPP 2.0.1/2.1 Part 2 §A00,
5//! identical to the OCPP 1.6 security whitepaper profiles).
6//!
7//! | Profile | Transport | Station authentication |
8//! |---|---|---|
9//! | 0 (none configured) | any | password if one is set (network-level security) |
10//! | 1 | plain only | HTTP Basic |
11//! | 2 | TLS | HTTP Basic |
12//! | 3 | TLS | client certificate: valid chain, O = CSO name, CN = serial number |
13//!
14//! A00.FR.002: a station connecting with a different profile than configured is refused.
15
16use crate::tls::TlsInfo;
17
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum Denied {
20    /// The station uses another profile than the one configured for it.
21    ProfileMismatch(&'static str),
22    /// Missing or wrong Basic credentials.
23    BadCredentials,
24    /// Profile 3: missing, invalid or foreign client certificate.
25    InvalidCertificate(String),
26}
27
28pub struct Inputs<'a> {
29    pub profile: Option<i16>,
30    pub tls: Option<&'a TlsInfo>,
31    /// `None`: no Authorization header; `Some(ok)`: credentials checked.
32    pub basic: Option<bool>,
33    pub has_password: bool,
34    pub require_password: bool,
35    pub identity: &'a str,
36    pub serial_number: Option<&'a str>,
37    pub cso_name: Option<&'a str>,
38}
39
40pub fn check(i: &Inputs<'_>) -> Result<(), Denied> {
41    let basic_ok = || match i.basic {
42        Some(true) => Ok(()),
43        _ => Err(Denied::BadCredentials),
44    };
45    match i.profile {
46        None | Some(0) => {
47            if i.has_password || i.require_password {
48                basic_ok()
49            } else {
50                Ok(())
51            }
52        }
53        Some(1) => {
54            if i.tls.is_some() {
55                return Err(Denied::ProfileMismatch(
56                    "station is configured for Security Profile 1 (unsecured transport)",
57                ));
58            }
59            basic_ok()
60        }
61        Some(2) => {
62            if i.tls.is_none() {
63                return Err(Denied::ProfileMismatch(
64                    "station is configured for Security Profile 2 (TLS required)",
65                ));
66            }
67            basic_ok()
68        }
69        Some(3) => {
70            let Some(tls) = i.tls else {
71                return Err(Denied::ProfileMismatch(
72                    "station is configured for Security Profile 3 (TLS with client certificate required)",
73                ));
74            };
75            let Some(cert) = &tls.client_cert else {
76                return Err(Denied::InvalidCertificate(
77                    "no client certificate presented".into(),
78                ));
79            };
80            if !cert.verified {
81                return Err(Denied::InvalidCertificate(
82                    cert.error
83                        .clone()
84                        .unwrap_or_else(|| "certificate not verified".into()),
85                ));
86            }
87            // A00.FR.404: owned by the CSO.
88            if let Some(cso) = i.cso_name
89                && !cert
90                    .organization
91                    .as_deref()
92                    .is_some_and(|o| o.contains(cso))
93            {
94                return Err(Denied::InvalidCertificate(format!(
95                    "organizationName {:?} does not contain the CSO name",
96                    cert.organization
97                )));
98            }
99            // A00.FR.405: CN contains the station's serial number. The OCPP identity is
100            // accepted too, as deployments commonly use the serial number as identity.
101            let cn = cert.common_name.as_deref().unwrap_or("");
102            let matches = (!cn.is_empty() && cn.contains(i.identity))
103                || i.serial_number
104                    .is_some_and(|s| !s.is_empty() && cn.contains(s));
105            if !matches {
106                return Err(Denied::InvalidCertificate(format!(
107                    "commonName {cn:?} does not match the charging station"
108                )));
109            }
110            Ok(())
111        }
112        Some(_) => Err(Denied::ProfileMismatch(
113            "invalid security profile configured",
114        )),
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::tls::ClientCert;
122
123    fn tls(cert: Option<(&str, &str, bool)>) -> TlsInfo {
124        TlsInfo {
125            version: "TLSv1.3".into(),
126            cipher: "x".into(),
127            client_cert: cert.map(|(o, cn, verified)| ClientCert {
128                common_name: Some(cn.into()),
129                organization: Some(o.into()),
130                verified,
131                error: (!verified).then(|| "unable to get local issuer certificate".into()),
132                serial_hex: "01".into(),
133            }),
134        }
135    }
136
137    fn inputs<'a>(profile: Option<i16>, t: Option<&'a TlsInfo>, basic: Option<bool>) -> Inputs<'a> {
138        Inputs {
139            profile,
140            tls: t,
141            basic,
142            has_password: true,
143            require_password: false,
144            identity: "CS1",
145            serial_number: Some("SN-9"),
146            cso_name: Some("TestCSO"),
147        }
148    }
149
150    #[test]
151    fn profiles() {
152        let plain = None;
153        let t = tls(None);
154        let good = tls(Some(("TestCSO", "CS1", true)));
155        let by_serial = tls(Some(("TestCSO", "SN-9", true)));
156        let other_org = tls(Some(("OtherCSO", "CS1", true)));
157        let untrusted = tls(Some(("TestCSO", "CS1", false)));
158        let wrong_cn = tls(Some(("TestCSO", "CS2", true)));
159
160        assert_eq!(check(&inputs(Some(1), plain, Some(true))), Ok(()));
161        assert_eq!(
162            check(&inputs(Some(1), plain, None)),
163            Err(Denied::BadCredentials)
164        );
165        assert!(matches!(
166            check(&inputs(Some(1), Some(&t), Some(true))),
167            Err(Denied::ProfileMismatch(_))
168        ));
169
170        assert_eq!(check(&inputs(Some(2), Some(&t), Some(true))), Ok(()));
171        assert_eq!(
172            check(&inputs(Some(2), Some(&t), Some(false))),
173            Err(Denied::BadCredentials)
174        );
175        assert!(matches!(
176            check(&inputs(Some(2), plain, Some(true))),
177            Err(Denied::ProfileMismatch(_))
178        ));
179
180        assert_eq!(check(&inputs(Some(3), Some(&good), None)), Ok(()));
181        assert_eq!(check(&inputs(Some(3), Some(&by_serial), None)), Ok(()));
182        for bad in [&t, &other_org, &untrusted, &wrong_cn] {
183            assert!(matches!(
184                check(&inputs(Some(3), Some(bad), None)),
185                Err(Denied::InvalidCertificate(_))
186            ));
187        }
188        assert!(matches!(
189            check(&inputs(Some(3), plain, None)),
190            Err(Denied::ProfileMismatch(_))
191        ));
192
193        let mut open = inputs(None, plain, None);
194        open.has_password = false;
195        assert_eq!(check(&open), Ok(()));
196        open.require_password = true;
197        assert_eq!(check(&open), Err(Denied::BadCredentials));
198    }
199}