UNPKG

@citrineos/util

Version:

The OCPP util module which supplies helpful utilities like cache and queue connectors, etc.

534 lines 22.9 kB
// SPDX-FileCopyrightText: 2025 Contributors to the CitrineOS Project // // SPDX-License-Identifier: Apache-2.0 import { OCPP2_0_1 } from '@citrineos/base'; import { VariableAttribute } from '@citrineos/data'; import { Logger } from 'tslog'; import { calculateCheckDigit } from './emaidCheckDigitCalculator.js'; import { getNumberOfFractionDigit } from './parser.js'; /** * Validate a language tag is an RFC-5646 tag, see: {@link https://tools.ietf.org/html/rfc5646}, * example: US English is: "en-US" * * @param languageTag * @returns {boolean} true if the languageTag is an RFC-5646 tag */ export function validateLanguageTag(languageTag) { if (!languageTag.trim()) { console.log('Empty language tag'); return false; } return /^((?:(en-GB-oed|i-ami|i-bnn|i-default|i-enochian|i-hak|i-klingon|i-lux|i-mingo|i-navajo|i-pwn|i-tao|i-tay|i-tsu|sgn-BE-FR|sgn-BE-NL|sgn-CH-DE)|(art-lojban|cel-gaulish|no-bok|no-nyn|zh-guoyu|zh-hakka|zh-min|zh-min-nan|zh-xiang))|((?:([A-Za-z]{2,3}(-(?:[A-Za-z]{3}(-[A-Za-z]{3}){0,2}))?)|[A-Za-z]{4}|[A-Za-z]{5,8})(-(?:[A-Za-z]{4}))?(-(?:[A-Za-z]{2}|[0-9]{3}))?(-(?:[A-Za-z0-9]{5,8}|[0-9][A-Za-z0-9]{3}))*(-(?:[0-9A-WY-Za-wy-z](-[A-Za-z0-9]{2,8})+))*(-(?:x(-[A-Za-z0-9]{1,8})+))?)|(?:x(-[A-Za-z0-9]{1,8})+))$/.test(languageTag); } /** * Validate constraints of ChargingProfileType defined in OCPP 2.0.1 * * @param chargingProfileType ChargingProfileType from the request * @param tenantId tenant id the profile belongs to * @param stationId station id * @param deviceModelRepository deviceModelRepository * @param chargingProfileRepository chargingProfileRepository * @param transactionEventRepository transactionEventRepository * @param logger logger * @param evseId evse id */ export async function validateChargingProfileType(chargingProfileType, tenantId, stationId, deviceModelRepository, chargingProfileRepository, transactionEventRepository, logger, evseId) { if (chargingProfileType.stackLevel < 0) { throw new Error('Lowest Stack level is 0'); } if (chargingProfileType.chargingProfilePurpose === OCPP2_0_1.ChargingProfilePurposeEnumType.ChargingStationMaxProfile && evseId !== 0) { throw new Error('When chargingProfilePurpose is ChargingStationMaxProfile, evseId SHALL be 0'); } if (chargingProfileType.chargingProfilePurpose !== OCPP2_0_1.ChargingProfilePurposeEnumType.TxProfile && chargingProfileType.transactionId) { throw new Error('transactionId SHALL only be included when ChargingProfilePurpose is set to TxProfile.'); } let receivedChargingNeeds; if (chargingProfileType.transactionId && evseId) { const transaction = await transactionEventRepository.readTransactionByStationIdAndTransactionId(tenantId, stationId, chargingProfileType.transactionId); if (!transaction) { throw new Error(`Transaction ${chargingProfileType.transactionId} not found on station ${stationId}.`); } const evse = await deviceModelRepository.findEvseByIdAndConnectorId(tenantId, evseId, null); if (!evse) { throw new Error(`Evse ${evseId} not found.`); } logger.info(`Found evse: ${JSON.stringify(evse)}`); receivedChargingNeeds = await chargingProfileRepository.findChargingNeedsByEvseDBIdAndTransactionDBId(tenantId, evse.databaseId, transaction.id); logger.info(`Found ChargingNeeds: ${JSON.stringify(receivedChargingNeeds)}`); } const periodsPerSchedules = await deviceModelRepository.readAllByQuerystring(tenantId, { tenantId: tenantId, stationId: stationId, component_name: 'SmartChargingCtrlr', variable_name: 'PeriodsPerSchedule', type: OCPP2_0_1.AttributeEnumType.Actual, }); logger.info(`Found PeriodsPerSchedule: ${JSON.stringify(periodsPerSchedules)}`); let periodsPerSchedule; if (periodsPerSchedules.length > 0 && periodsPerSchedules[0].value) { periodsPerSchedule = Number(periodsPerSchedules[0].value); } for (const chargingSchedule of chargingProfileType.chargingSchedule) { if (chargingSchedule.minChargingRate && getNumberOfFractionDigit(chargingSchedule.minChargingRate) > 1) { throw new Error(`chargingSchedule ${chargingSchedule.id}: minChargingRate accepts at most one digit fraction (e.g. 8.1).`); } if (periodsPerSchedule && chargingSchedule.chargingSchedulePeriod.length > periodsPerSchedule) { throw new Error(`ChargingSchedule ${chargingSchedule.id}: The number of chargingSchedulePeriod SHALL not exceed ${periodsPerSchedule}.`); } for (const chargingSchedulePeriod of chargingSchedule.chargingSchedulePeriod) { if (getNumberOfFractionDigit(chargingSchedulePeriod.limit) > 1) { throw new Error(`ChargingSchedule ${chargingSchedule.id}: chargingSchedulePeriod limit accepts at most one digit fraction (e.g. 8.1).`); } if (receivedChargingNeeds) { if (receivedChargingNeeds.acChargingParameters) { // EV AC charging if (!chargingSchedulePeriod.numberPhases) { chargingSchedulePeriod.numberPhases = 3; } } else if (receivedChargingNeeds.dcChargingParameters) { // EV DC charging chargingSchedulePeriod.numberPhases = undefined; } } } if (chargingSchedule.salesTariff) { if (receivedChargingNeeds && receivedChargingNeeds.maxScheduleTuples && chargingSchedule.salesTariff.salesTariffEntry.length > receivedChargingNeeds.maxScheduleTuples) { throw new Error(`ChargingSchedule ${chargingSchedule.id}: The number of SalesTariffEntry elements (${chargingSchedule.salesTariff.salesTariffEntry.length}) SHALL not exceed maxScheduleTuples (${receivedChargingNeeds.maxScheduleTuples}).`); } for (const salesTariffEntry of chargingSchedule.salesTariff.salesTariffEntry) { if (salesTariffEntry.consumptionCost) { for (const consumptionCost of salesTariffEntry.consumptionCost) { if (consumptionCost.cost) { for (const cost of consumptionCost.cost) { if (cost.amountMultiplier && (cost.amountMultiplier > 3 || cost.amountMultiplier < -3)) { throw new Error(`ChargingSchedule ${chargingSchedule.id}: amountMultiplier SHALL be in [-3, 3].`); } } } } } } } } } /** * Validate ISO15693 ID token format * ISO 15693 UID should be exactly 8 bytes (16 hex characters) */ export function validateISO15693IdToken(idToken) { return !!idToken && idToken.length === 16 && /^[0-9A-Fa-f]+$/.test(idToken); } /** * Validate ISO14443 ID token format * ISO 14443 UID should be 4 or 7 bytes (8 or 14 hex characters) */ export function validateISO14443IdToken(idToken) { return (!!idToken && (idToken.length === 8 || idToken.length === 14) && /^[0-9A-Fa-f]+$/.test(idToken)); } /** * Validate identifier string format per OCPP 2.0.1. We expect this validation already from the JSON schema, * but we add this extra validation to be sure. * Only allows: a-z, A-Z, 0-9, *, -, _, =, :, +, |, @, . */ export function validateIdentifierStringIdToken(idToken) { return !!idToken && /^[a-zA-Z0-9*\-_=:+|@.]+$/.test(idToken); } /** * Validates an eMAID string according to eMI³ specifications * @param emaid - The eMAID string to validate * @returns errors - String array with errors, empty if valid */ export function validateEMAIDIdToken(emaid) { const errors = []; // Remove optional separators and convert to uppercase const separator = '-'; let cleanedEmaid = emaid.replace(new RegExp(separator, 'g'), '').toUpperCase(); // For backwards compatibility with DIN SPEC 91286 and eMAIDs without ID type at position 6 if (cleanedEmaid.length === 13) { // Insert id type 'C' cleanedEmaid = cleanedEmaid.substring(0, 5) + 'C' + cleanedEmaid.substring(5); } else if (cleanedEmaid.length === 14 && cleanedEmaid.substring(5, 6) !== 'C') { // Insert id type 'C' and prune check digit cleanedEmaid = cleanedEmaid.substring(0, 5) + 'C' + cleanedEmaid.substring(5, 13); } // Check overall length (14 or 15 characters without separators) if (cleanedEmaid.length < 14 || cleanedEmaid.length > 15) { errors.push(`Invalid length: ${cleanedEmaid.length} characters (expected 14 or 15)`); return errors; } // Validate character set (alphanumeric only) if (!/^[A-Z0-9]+$/.test(cleanedEmaid)) { errors.push('eMAID must contain only alphanumeric characters (and optional hyphens as separators)'); return errors; } // Parse components const countryCode = cleanedEmaid.substring(0, 2); const providerId = cleanedEmaid.substring(2, 5); const idType = cleanedEmaid.substring(5, 6); const instance = cleanedEmaid.substring(6, 14); const checkDigit = cleanedEmaid.length === 15 ? cleanedEmaid.substring(14, 15) : undefined; // Validate Country Code (2 letters) if (!/^[A-Z]{2}$/.test(countryCode)) { errors.push('Country code must be exactly 2 letters'); } // Validate Provider ID (3 alphanumeric) if (!/^[A-Z0-9]{3}$/.test(providerId)) { errors.push('Provider ID must be exactly 3 alphanumeric characters'); } // Validate ID Type (must be 'C' for Contract) if (idType !== 'C') { errors.push(`ID Type must be 'C' for Contract (found: '${idType}')`); } // Validate Instance (8 alphanumeric) if (!/^[A-Z0-9]{8}$/.test(instance)) { errors.push('Instance must be exactly 8 alphanumeric characters'); } // If check digit is present, validate it if (checkDigit !== undefined) { try { const calculatedCheckDigit = calculateCheckDigit(cleanedEmaid.substring(0, 14)); if (checkDigit !== calculatedCheckDigit) { errors.push(`Invalid check digit: expected '${calculatedCheckDigit}', found '${checkDigit}'`); } } catch (error) { errors.push(`Check digit calculation error: ${error instanceof Error ? error.message : 'Unknown error'}`); } } return errors; } /** * Validate NoAuthorization ID token (should be empty) */ export function validateNoAuthorizationIdToken(idToken) { return !idToken || idToken.length === 0; } /** * ID token validator - routes to appropriate validator based on type * Returns validation result with detailed error message if invalid */ export function validateIdToken(idTokenType, idToken) { switch (idTokenType) { case OCPP2_0_1.IdTokenEnumType.ISO15693: if (validateISO15693IdToken(idToken)) { return { isValid: true }; } return { isValid: false, errorMessage: 'ISO15693 tokens must be exactly 16 hexadecimal characters (0-9, A-F)', }; case OCPP2_0_1.IdTokenEnumType.ISO14443: if (validateISO14443IdToken(idToken)) { return { isValid: true }; } return { isValid: false, errorMessage: 'ISO14443 tokens must be either 8 or 14 hexadecimal characters (0-9, A-F)', }; case OCPP2_0_1.IdTokenEnumType.NoAuthorization: if (validateNoAuthorizationIdToken(idToken)) { return { isValid: true }; } return { isValid: false, errorMessage: 'NoAuthorization tokens must be empty', }; case OCPP2_0_1.IdTokenEnumType.KeyCode: if (validateIdentifierStringIdToken(idToken)) { return { isValid: true }; } return { isValid: false, errorMessage: 'KeyCode tokens must contain only letters, numbers, and characters: * - _ = : + | @ .', }; case OCPP2_0_1.IdTokenEnumType.Local: if (validateIdentifierStringIdToken(idToken)) { return { isValid: true }; } return { isValid: false, errorMessage: 'Local tokens must contain only letters, numbers, and characters: * - _ = : + | @ .', }; case OCPP2_0_1.IdTokenEnumType.MacAddress: if (validateIdentifierStringIdToken(idToken)) { return { isValid: true }; } return { isValid: false, errorMessage: 'MacAddress tokens must contain only letters, numbers, and characters: * - _ = : + | @ .', }; case OCPP2_0_1.IdTokenEnumType.Central: if (validateIdentifierStringIdToken(idToken)) { return { isValid: true }; } return { isValid: false, errorMessage: 'Central tokens must contain only letters, numbers, and characters: * - _ = : + | @ .', }; case OCPP2_0_1.IdTokenEnumType.eMAID: { const errors = validateEMAIDIdToken(idToken); if (errors.length === 0) { return { isValid: true }; } return { isValid: false, errorMessage: 'eMAID tokens must follow the eMI3 format: ' + errors.join(', '), }; } default: return { isValid: true, // IdTokenType is already validated by JSON schema, so types not listed here are considered valid }; } } /** * Validate ASCII content - only printable ASCII allowed (characters 32-126) * @param content Content string to validate * @returns {boolean} true if content contains only printable ASCII characters */ export function validateASCIIContent(content) { if (!content) return true; // Empty content is valid // Printable ASCII: space (32) through tilde (126) return /^[\x20-\x7E]*$/.test(content); } /** * Validate HTML content - checks for basic HTML structure validity * @param content Content string to validate * @returns {boolean} true if content appears to be valid HTML */ export function validateHTMLContent(content) { if (!content) return true; // Empty content is valid // Basic HTML validation: check for properly matched tags // This is a simplified check - real HTML validation would require a full parser const tagPattern = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*>/g; const tags = []; let hasTags = false; let match; while ((match = tagPattern.exec(content)) !== null) { const tag = match[0]; const tagName = match[1].toLowerCase(); hasTags = true; // Skip self-closing tags and void elements const voidElements = [ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr', ]; if (tag.endsWith('/>') || voidElements.includes(tagName)) { continue; } if (tag.startsWith('</')) { // Closing tag if (tags.length === 0 || tags[tags.length - 1] !== tagName) { return false; // Mismatched closing tag } tags.pop(); } else { // Opening tag tags.push(tagName); } } if (!hasTags) return false; // No HTML tags found // All tags should be closed return tags.length === 0; } /** * Validate URI content - checks if content is a valid URI * @param content Content string to validate * @returns {boolean} true if content is a valid URI */ export function validateURIContent(content) { if (!content) return false; // Empty URI is not valid try { // Try to parse as URL - will throw if invalid new URL(content); return true; } catch { // If absolute URL parsing fails, check if it's a valid relative URI // A relative URI should at least not contain invalid characters // and should follow basic URI syntax const uriPattern = /^[a-zA-Z][a-zA-Z0-9+.-]*:|^\/|^[a-zA-Z0-9._~:/?#[\]@!$&'()*+,;=-]+$/; return uriPattern.test(content); } } /** * Validate UTF-8 content - in JavaScript, strings are already UTF-16 encoded * This function checks for invalid surrogate pairs and control characters * @param content Content string to validate * @returns {boolean} true if content is valid UTF-8 */ export function validateUTF8Content(content) { if (!content) return true; // Empty content is valid // Check for unpaired surrogate characters which indicate invalid UTF-16/UTF-8 for (let i = 0; i < content.length; i++) { const charCode = content.charCodeAt(i); // Check for high surrogate without low surrogate if (charCode >= 0xd800 && charCode <= 0xdbff) { if (i + 1 >= content.length) { return false; // High surrogate at end of string } const nextCharCode = content.charCodeAt(i + 1); if (nextCharCode < 0xdc00 || nextCharCode > 0xdfff) { return false; // High surrogate not followed by low surrogate } i++; // Skip the low surrogate } // Check for low surrogate without high surrogate else if (charCode >= 0xdc00 && charCode <= 0xdfff) { return false; // Low surrogate without preceding high surrogate } } return true; } /** * Message content validator - routes to appropriate validator based on format * Returns validation result with detailed error message if invalid * @param format Message format type (ASCII, HTML, URI, UTF8) * @param content Message content to validate * @returns {ValidationResult} Validation result with error message if invalid */ export function validateMessageContent(format, content) { switch (format) { case OCPP2_0_1.MessageFormatEnumType.ASCII: if (validateASCIIContent(content)) { return { isValid: true }; } return { isValid: false, errorMessage: 'ASCII format requires content to contain only printable ASCII characters (space through tilde)', }; case OCPP2_0_1.MessageFormatEnumType.HTML: if (validateHTMLContent(content)) { return { isValid: true }; } return { isValid: false, errorMessage: 'HTML format requires properly matched opening and closing tags', }; case OCPP2_0_1.MessageFormatEnumType.URI: if (validateURIContent(content)) { return { isValid: true }; } return { isValid: false, errorMessage: 'URI format requires a valid URI that the Charging Station can download', }; case OCPP2_0_1.MessageFormatEnumType.UTF8: if (validateUTF8Content(content)) { return { isValid: true }; } return { isValid: false, errorMessage: 'UTF8 format requires valid UTF-8 encoded content without unpaired surrogate characters', }; default: return { isValid: false, errorMessage: `Unknown message format: ${format}`, }; } } /** * Validate a complete MessageContentType object * Convenience function that validates both language tag (if present) and content against format * @param messageContent MessageContentType object to validate * @returns {ValidationResult} Validation result with error message if invalid */ export function validateMessageContentType(messageContent) { // Validate language tag if present if (messageContent.language != null && !validateLanguageTag(messageContent.language)) { return { isValid: false, errorMessage: `Invalid language tag: ${messageContent.language}. Must be an RFC-5646 language tag (e.g., "en-US")`, }; } // Validate content against format return validateMessageContent(messageContent.format, messageContent.content); } /** * Validate PEM-encoded Certificate Signing Request (CSR) * According to RFC 2986, CSR must be PEM-encoded with proper headers and valid base64 content * @param csr CSR string to validate * @returns {ValidationResult} Validation result with error message if invalid */ export function validatePEMEncodedCSR(csr) { if (!csr || !csr.trim()) { return { isValid: false, errorMessage: 'CSR cannot be empty', }; } const trimmedCSR = csr.trim(); // Check for PEM headers const beginHeader = '-----BEGIN CERTIFICATE REQUEST-----'; const endHeader = '-----END CERTIFICATE REQUEST-----'; if (!trimmedCSR.includes(beginHeader)) { return { isValid: false, errorMessage: 'CSR must contain BEGIN CERTIFICATE REQUEST header', }; } if (!trimmedCSR.includes(endHeader)) { return { isValid: false, errorMessage: 'CSR must contain END CERTIFICATE REQUEST header', }; } // Extract content between headers const beginIndex = trimmedCSR.indexOf(beginHeader) + beginHeader.length; const endIndex = trimmedCSR.indexOf(endHeader); if (beginIndex >= endIndex) { return { isValid: false, errorMessage: 'CSR headers are in wrong order', }; } const content = trimmedCSR.substring(beginIndex, endIndex).trim(); // Check that there's actual content if (content.replace(/\s/g, '').length === 0) { return { isValid: false, errorMessage: 'CSR content is empty', }; } // Validate base64 content (allows A-Z, a-z, 0-9, +, /, =, and whitespace) const base64Pattern = /^[A-Za-z0-9+/=\s]+$/; if (!base64Pattern.test(content)) { return { isValid: false, errorMessage: 'CSR content contains invalid characters for base64 encoding', }; } return { isValid: true }; } //# sourceMappingURL=validator.js.map