Skip to main content

ocpp_service/
auth.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! Charging-station authentication: HTTP Basic (OCPP Security Profiles 1 and 2).
5//!
6//! Username = station identity. The password is treated as bytes: OCPP 1.6-J
7//! §6.2.2 uses a raw 20-byte key, OCPP 2.x a UTF-8 passwordString, and many
8//! 1.6 stations send the 40-character hex form of the key. Stored as an
9//! Argon2id PHC string (salted, as recommended by OCPP-J 1.6 §6.2.2).
10
11use std::collections::HashMap;
12use std::sync::Mutex;
13
14use argon2::password_hash::rand_core::OsRng;
15use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
16use argon2::{Algorithm, Argon2, Params, Version};
17use tokio::sync::Semaphore;
18
19/// Argon2id with moderate memory: station secrets are machine-generated, and
20/// reconnect storms after a restart must not exhaust memory.
21fn argon2() -> Argon2<'static> {
22    Argon2::new(
23        Algorithm::Argon2id,
24        Version::V0x13,
25        Params::new(8 * 1024, 2, 1, None).expect("valid params"),
26    )
27}
28
29pub fn hash_password(password: &[u8]) -> String {
30    let salt = SaltString::generate(&mut OsRng);
31    argon2()
32        .hash_password(password, &salt)
33        .expect("argon2 hashing")
34        .to_string()
35}
36
37fn verify_one(password: &[u8], phc: &str) -> bool {
38    PasswordHash::new(phc).is_ok_and(|h| argon2().verify_password(password, &h).is_ok())
39}
40
41fn hex_decode(s: &[u8]) -> Option<Vec<u8>> {
42    if !s.len().is_multiple_of(2) {
43        return None;
44    }
45    let v = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
46    s.chunks(2)
47        .map(|p| Some(v(p[0])? << 4 | v(p[1])?))
48        .collect()
49}
50
51fn hex_encode(b: &[u8], upper: bool) -> Vec<u8> {
52    b.iter()
53        .flat_map(|x| {
54            if upper {
55                format!("{x:02X}")
56            } else {
57                format!("{x:02x}")
58            }
59            .into_bytes()
60        })
61        .collect()
62}
63
64/// Accepts the password as sent, or its raw/hex counterpart for 20-byte 1.6 keys.
65fn verify_password(password: &[u8], phc: &str) -> bool {
66    if verify_one(password, phc) {
67        return true;
68    }
69    if password.len() == 40
70        && let Some(raw) = hex_decode(password)
71    {
72        return verify_one(&raw, phc);
73    }
74    if password.len() == 20 {
75        return verify_one(&hex_encode(password, true), phc)
76            || verify_one(&hex_encode(password, false), phc);
77    }
78    false
79}
80
81/// Parses `Authorization: Basic …` into (username, password bytes).
82pub fn parse_basic(header: &str) -> Option<(String, Vec<u8>)> {
83    let (scheme, value) = header.trim().split_once(' ')?;
84    if !scheme.eq_ignore_ascii_case("basic") {
85        return None;
86    }
87    let decoded = ocpp_ws::base64::decode(value.trim())?;
88    let colon = decoded.iter().position(|&b| b == b':')?;
89    let user = String::from_utf8(decoded[..colon].to_vec()).ok()?;
90    Some((user, decoded[colon + 1..].to_vec()))
91}
92
93/// Verifies credentials with bounded concurrency and a cache of recently
94/// verified (identity, password, stored hash) triples.
95pub struct Authenticator {
96    permits: Semaphore,
97    verified: Mutex<HashMap<String, (Vec<u8>, String)>>,
98}
99
100impl Authenticator {
101    pub fn new(concurrency: usize) -> Self {
102        Authenticator {
103            permits: Semaphore::new(concurrency.max(1)),
104            verified: Mutex::new(HashMap::new()),
105        }
106    }
107
108    pub async fn verify(&self, identity: &str, password: &[u8], phc: &str) -> bool {
109        {
110            let cache = self.verified.lock().unwrap_or_else(|e| e.into_inner());
111            if let Some((pw, hash)) = cache.get(identity)
112                && pw.as_slice() == password
113                && hash == phc
114            {
115                return true;
116            }
117        }
118        let Ok(_permit) = self.permits.acquire().await else {
119            return false;
120        };
121        let (pw, h) = (password.to_vec(), phc.to_owned());
122        let ok = tokio::task::spawn_blocking(move || verify_password(&pw, &h))
123            .await
124            .unwrap_or(false);
125        let mut cache = self.verified.lock().unwrap_or_else(|e| e.into_inner());
126        if ok {
127            cache.insert(identity.to_owned(), (password.to_vec(), phc.to_owned()));
128        } else {
129            cache.remove(identity);
130        }
131        ok
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn basic_header_with_binary_key() {
141        let (u, p) = parse_basic("Basic QUwxMDAwOgABAgMEBQYH////////////////").unwrap();
142        assert_eq!(u, "AL1000");
143        assert_eq!(p.len(), 20);
144        assert_eq!(&p[..8], &[0, 1, 2, 3, 4, 5, 6, 7]);
145        assert!(parse_basic("Bearer abc").is_none());
146    }
147
148    #[tokio::test]
149    async fn raw_and_hex_forms_match() {
150        let key: Vec<u8> = (0u8..20).collect();
151        let phc = hash_password(&key);
152        let a = Authenticator::new(2);
153        assert!(a.verify("CS1", &key, &phc).await);
154        assert!(
155            a.verify("CS1", b"000102030405060708090a0b0c0d0e0f10111213", &phc)
156                .await
157        );
158        assert!(!a.verify("CS1", b"wrong", &phc).await);
159        let phc2 = hash_password(b"a-utf8-password-of-2.0.1");
160        assert!(a.verify("CS2", b"a-utf8-password-of-2.0.1", &phc2).await);
161        assert!(a.verify("CS2", b"a-utf8-password-of-2.0.1", &phc2).await);
162    }
163}