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)

//! Charging-station authentication: HTTP Basic (OCPP Security Profiles 1 and 2).
//!
//! Username = station identity. The password is treated as bytes: OCPP 1.6-J
//! §6.2.2 uses a raw 20-byte key, OCPP 2.x a UTF-8 passwordString, and many
//! 1.6 stations send the 40-character hex form of the key. Stored as an
//! Argon2id PHC string (salted, as recommended by OCPP-J 1.6 §6.2.2).

use std::collections::HashMap;
use std::sync::Mutex;

use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString};
use argon2::{Algorithm, Argon2, Params, Version};
use tokio::sync::Semaphore;

/// Argon2id with moderate memory: station secrets are machine-generated, and
/// reconnect storms after a restart must not exhaust memory.
fn argon2() -> Argon2<'static> {
    Argon2::new(
        Algorithm::Argon2id,
        Version::V0x13,
        Params::new(8 * 1024, 2, 1, None).expect("valid params"),
    )
}

pub fn hash_password(password: &[u8]) -> String {
    let salt = SaltString::generate(&mut OsRng);
    argon2()
        .hash_password(password, &salt)
        .expect("argon2 hashing")
        .to_string()
}

fn verify_one(password: &[u8], phc: &str) -> bool {
    PasswordHash::new(phc).is_ok_and(|h| argon2().verify_password(password, &h).is_ok())
}

fn hex_decode(s: &[u8]) -> Option<Vec<u8>> {
    if !s.len().is_multiple_of(2) {
        return None;
    }
    let v = |c: u8| (c as char).to_digit(16).map(|d| d as u8);
    s.chunks(2)
        .map(|p| Some(v(p[0])? << 4 | v(p[1])?))
        .collect()
}

fn hex_encode(b: &[u8], upper: bool) -> Vec<u8> {
    b.iter()
        .flat_map(|x| {
            if upper {
                format!("{x:02X}")
            } else {
                format!("{x:02x}")
            }
            .into_bytes()
        })
        .collect()
}

/// Accepts the password as sent, or its raw/hex counterpart for 20-byte 1.6 keys.
fn verify_password(password: &[u8], phc: &str) -> bool {
    if verify_one(password, phc) {
        return true;
    }
    if password.len() == 40
        && let Some(raw) = hex_decode(password)
    {
        return verify_one(&raw, phc);
    }
    if password.len() == 20 {
        return verify_one(&hex_encode(password, true), phc)
            || verify_one(&hex_encode(password, false), phc);
    }
    false
}

/// Parses `Authorization: Basic …` into (username, password bytes).
pub fn parse_basic(header: &str) -> Option<(String, Vec<u8>)> {
    let (scheme, value) = header.trim().split_once(' ')?;
    if !scheme.eq_ignore_ascii_case("basic") {
        return None;
    }
    let decoded = ocpp_ws::base64::decode(value.trim())?;
    let colon = decoded.iter().position(|&b| b == b':')?;
    let user = String::from_utf8(decoded[..colon].to_vec()).ok()?;
    Some((user, decoded[colon + 1..].to_vec()))
}

/// Verifies credentials with bounded concurrency and a cache of recently
/// verified (identity, password, stored hash) triples.
pub struct Authenticator {
    permits: Semaphore,
    verified: Mutex<HashMap<String, (Vec<u8>, String)>>,
}

impl Authenticator {
    pub fn new(concurrency: usize) -> Self {
        Authenticator {
            permits: Semaphore::new(concurrency.max(1)),
            verified: Mutex::new(HashMap::new()),
        }
    }

    pub async fn verify(&self, identity: &str, password: &[u8], phc: &str) -> bool {
        {
            let cache = self.verified.lock().unwrap_or_else(|e| e.into_inner());
            if let Some((pw, hash)) = cache.get(identity)
                && pw.as_slice() == password
                && hash == phc
            {
                return true;
            }
        }
        let Ok(_permit) = self.permits.acquire().await else {
            return false;
        };
        let (pw, h) = (password.to_vec(), phc.to_owned());
        let ok = tokio::task::spawn_blocking(move || verify_password(&pw, &h))
            .await
            .unwrap_or(false);
        let mut cache = self.verified.lock().unwrap_or_else(|e| e.into_inner());
        if ok {
            cache.insert(identity.to_owned(), (password.to_vec(), phc.to_owned()));
        } else {
            cache.remove(identity);
        }
        ok
    }
}

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

    #[test]
    fn basic_header_with_binary_key() {
        let (u, p) = parse_basic("Basic QUwxMDAwOgABAgMEBQYH////////////////").unwrap();
        assert_eq!(u, "AL1000");
        assert_eq!(p.len(), 20);
        assert_eq!(&p[..8], &[0, 1, 2, 3, 4, 5, 6, 7]);
        assert!(parse_basic("Bearer abc").is_none());
    }

    #[tokio::test]
    async fn raw_and_hex_forms_match() {
        let key: Vec<u8> = (0u8..20).collect();
        let phc = hash_password(&key);
        let a = Authenticator::new(2);
        assert!(a.verify("CS1", &key, &phc).await);
        assert!(
            a.verify("CS1", b"000102030405060708090a0b0c0d0e0f10111213", &phc)
                .await
        );
        assert!(!a.verify("CS1", b"wrong", &phc).await);
        let phc2 = hash_password(b"a-utf8-password-of-2.0.1");
        assert!(a.verify("CS2", b"a-utf8-password-of-2.0.1", &phc2).await);
        assert!(a.verify("CS2", b"a-utf8-password-of-2.0.1", &phc2).await);
    }
}