@road-labs/ocmf 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.UnknownSignatureMimeType = exports.UnknownSignatureEncoding = exports.CurveMismatchError = void 0;
4
+ class CurveMismatchError extends Error {
5
+ }
6
+ exports.CurveMismatchError = CurveMismatchError;
7
+ class UnknownSignatureEncoding extends Error {
8
+ }
9
+ exports.UnknownSignatureEncoding = UnknownSignatureEncoding;
10
+ class UnknownSignatureMimeType extends Error {
11
+ }
12
+ exports.UnknownSignatureMimeType = UnknownSignatureMimeType;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
+ for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
+ };
16
+ var __importDefault = (this && this.__importDefault) || function (mod) {
17
+ return (mod && mod.__esModule) ? mod : { "default": mod };
18
+ };
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.Verifier = exports.Signer = void 0;
21
+ __exportStar(require("./types"), exports);
22
+ __exportStar(require("./parse"), exports);
23
+ __exportStar(require("./signature"), exports);
24
+ __exportStar(require("./errors"), exports);
25
+ __exportStar(require("./verifier"), exports);
26
+ __exportStar(require("./signer"), exports);
27
+ var signer_1 = require("./signer");
28
+ Object.defineProperty(exports, "Signer", { enumerable: true, get: function () { return __importDefault(signer_1).default; } });
29
+ var verifier_1 = require("./verifier");
30
+ Object.defineProperty(exports, "Verifier", { enumerable: true, get: function () { return __importDefault(verifier_1).default; } });
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.decomposeValue = decomposeValue;
4
+ exports.parseSections = parseSections;
5
+ function decomposeValue(value) {
6
+ const parts = value.split('|');
7
+ if (parts.length !== 3) {
8
+ throw new Error('Signed data must be in OCMF|data|signature format');
9
+ }
10
+ const headerSection = parts[0];
11
+ const payloadDataSection = parts[1];
12
+ const signatureSection = parts[2];
13
+ if (headerSection !== 'OCMF') {
14
+ throw new Error('OCMF header required');
15
+ }
16
+ if (payloadDataSection.length === 0) {
17
+ throw new Error('Payload section cannot be empty');
18
+ }
19
+ if (signatureSection.length === 0) {
20
+ throw new Error('Signature section cannot be empty');
21
+ }
22
+ return {
23
+ header: headerSection,
24
+ payloadData: payloadDataSection,
25
+ signature: signatureSection,
26
+ };
27
+ }
28
+ function parseSections(sections) {
29
+ return {
30
+ header: sections.header,
31
+ payloadData: JSON.parse(sections.payloadData),
32
+ signature: JSON.parse(sections.signature),
33
+ };
34
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.signatureMethodFromId = signatureMethodFromId;
4
+ const signatureMethods = [
5
+ {
6
+ id: 'ECDSA-secp192r1-SHA256',
7
+ algorithm: 'ECDSA',
8
+ curve: 'secp192r1',
9
+ hash: 'SHA-256',
10
+ },
11
+ {
12
+ id: 'ECDSA-secp192k1-SHA256',
13
+ algorithm: 'ECDSA',
14
+ curve: 'secp192k1',
15
+ hash: 'SHA-256',
16
+ },
17
+ {
18
+ id: 'ECDSA-secp256r1-SHA256',
19
+ algorithm: 'ECDSA',
20
+ curve: 'secp256r1',
21
+ hash: 'SHA-256',
22
+ },
23
+ {
24
+ id: 'ECDSA-secp256k1-SHA256',
25
+ algorithm: 'ECDSA',
26
+ curve: 'secp256k1',
27
+ hash: 'SHA-256',
28
+ },
29
+ {
30
+ id: 'ECDSA-secp384r1-SHA256',
31
+ algorithm: 'ECDSA',
32
+ curve: 'secp384r1',
33
+ hash: 'SHA-256',
34
+ },
35
+ {
36
+ id: 'ECDSA-brainpool256r1-SHA256',
37
+ algorithm: 'ECDSA',
38
+ curve: 'brainpool256r1',
39
+ hash: 'SHA-256',
40
+ },
41
+ {
42
+ id: 'ECDSA-brainpool384r1-SHA256',
43
+ algorithm: 'ECDSA',
44
+ curve: 'brainpool384r1',
45
+ hash: 'SHA-256',
46
+ },
47
+ ];
48
+ function signatureMethodFromId(id) {
49
+ const signatureMethod = signatureMethods.find((signature) => signature.id === id);
50
+ if (!signatureMethod) {
51
+ throw new Error(`Unsupported signature method: ${id}`);
52
+ }
53
+ return signatureMethod;
54
+ }
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const signature_1 = require("./signature");
4
+ const types_1 = require("./types");
5
+ const errors_1 = require("./errors");
6
+ class Signer {
7
+ crypto;
8
+ constructor(crypto) {
9
+ this.crypto = crypto;
10
+ }
11
+ async sign(payload, privateKey, signatureMethodId) {
12
+ const signatureMethod = (0, signature_1.signatureMethodFromId)(signatureMethodId);
13
+ if (signatureMethod.curve !== privateKey.getCurve()) {
14
+ throw new errors_1.CurveMismatchError(`Expected ${privateKey.getCurve()}, actual ${signatureMethod.curve}`);
15
+ }
16
+ const payloadDataSegment = JSON.stringify(payload);
17
+ const textEncoder = new TextEncoder();
18
+ const payloadDataSegmentBytes = textEncoder.encode(payloadDataSegment);
19
+ const signature = await this.crypto.sign(payloadDataSegmentBytes, privateKey, signatureMethod.hash, 'sigvalue-der');
20
+ const signatureSegment = JSON.stringify({
21
+ SA: signatureMethod.id,
22
+ SD: bytesToHex(signature).toUpperCase(),
23
+ });
24
+ return [types_1.Header, payloadDataSegment, signatureSegment].join('|');
25
+ }
26
+ }
27
+ exports.default = Signer;
28
+ function bytesToHex(bytes) {
29
+ return Array.from(bytes)
30
+ .map((x) => x.toString(16).padStart(2, '0'))
31
+ .join('');
32
+ }
@@ -0,0 +1,4 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.Header = void 0;
4
+ exports.Header = 'OCMF';
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const signature_1 = require("./signature");
4
+ const parse_1 = require("./parse");
5
+ const errors_1 = require("./errors");
6
+ class Verifier {
7
+ crypto;
8
+ constructor(crypto) {
9
+ this.crypto = crypto;
10
+ }
11
+ async parseAndVerify(rawSignedData, publicKey) {
12
+ const sections = (0, parse_1.decomposeValue)(rawSignedData);
13
+ const signedData = (0, parse_1.parseSections)(sections);
14
+ const { signature } = signedData;
15
+ const signatureMethod = (0, signature_1.signatureMethodFromId)(signature.SA);
16
+ if (signatureMethod.curve !== publicKey.getCurve()) {
17
+ throw new errors_1.CurveMismatchError(`Expected ${publicKey.getCurve()}, actual ${signatureMethod.curve}`);
18
+ }
19
+ if (signature.SE && signature.SE !== 'hex') {
20
+ throw new errors_1.UnknownSignatureEncoding('Only hex encoded signatures are supported');
21
+ }
22
+ if (signature.SM && signature.SM !== 'application/x-der') {
23
+ throw new errors_1.UnknownSignatureMimeType('Only application/x-der encoded signatures are supported');
24
+ }
25
+ const textEncoder = new TextEncoder();
26
+ const payloadDataSegment = textEncoder.encode(sections.payloadData);
27
+ const signatureBytes = hexToBytes(signature.SD);
28
+ const verified = await this.crypto.verify(signatureBytes, payloadDataSegment, publicKey, signatureMethod.hash, 'sigvalue-der');
29
+ return {
30
+ verified,
31
+ value: verified ? signedData : undefined,
32
+ };
33
+ }
34
+ }
35
+ exports.default = Verifier;
36
+ function hexToBytes(hex) {
37
+ if (hex.length === 0 ||
38
+ hex.length % 2 !== 0 ||
39
+ !hex.match(/^[A-Za-f0-9]+$/)) {
40
+ throw new Error('Invalid hex string');
41
+ }
42
+ const bytes = hex.match(/.{2}/g)?.map((byte) => parseInt(byte, 16));
43
+ if (!bytes) {
44
+ throw new Error('Failed to map hex string');
45
+ }
46
+ return new Uint8Array(bytes);
47
+ }
@@ -0,0 +1,6 @@
1
+ export class CurveMismatchError extends Error {
2
+ }
3
+ export class UnknownSignatureEncoding extends Error {
4
+ }
5
+ export class UnknownSignatureMimeType extends Error {
6
+ }
@@ -0,0 +1,8 @@
1
+ export * from './types';
2
+ export * from './parse';
3
+ export * from './signature';
4
+ export * from './errors';
5
+ export * from './verifier';
6
+ export * from './signer';
7
+ export { default as Signer } from './signer';
8
+ export { default as Verifier } from './verifier';
@@ -0,0 +1,30 @@
1
+ export function decomposeValue(value) {
2
+ const parts = value.split('|');
3
+ if (parts.length !== 3) {
4
+ throw new Error('Signed data must be in OCMF|data|signature format');
5
+ }
6
+ const headerSection = parts[0];
7
+ const payloadDataSection = parts[1];
8
+ const signatureSection = parts[2];
9
+ if (headerSection !== 'OCMF') {
10
+ throw new Error('OCMF header required');
11
+ }
12
+ if (payloadDataSection.length === 0) {
13
+ throw new Error('Payload section cannot be empty');
14
+ }
15
+ if (signatureSection.length === 0) {
16
+ throw new Error('Signature section cannot be empty');
17
+ }
18
+ return {
19
+ header: headerSection,
20
+ payloadData: payloadDataSection,
21
+ signature: signatureSection,
22
+ };
23
+ }
24
+ export function parseSections(sections) {
25
+ return {
26
+ header: sections.header,
27
+ payloadData: JSON.parse(sections.payloadData),
28
+ signature: JSON.parse(sections.signature),
29
+ };
30
+ }
@@ -0,0 +1,51 @@
1
+ const signatureMethods = [
2
+ {
3
+ id: 'ECDSA-secp192r1-SHA256',
4
+ algorithm: 'ECDSA',
5
+ curve: 'secp192r1',
6
+ hash: 'SHA-256',
7
+ },
8
+ {
9
+ id: 'ECDSA-secp192k1-SHA256',
10
+ algorithm: 'ECDSA',
11
+ curve: 'secp192k1',
12
+ hash: 'SHA-256',
13
+ },
14
+ {
15
+ id: 'ECDSA-secp256r1-SHA256',
16
+ algorithm: 'ECDSA',
17
+ curve: 'secp256r1',
18
+ hash: 'SHA-256',
19
+ },
20
+ {
21
+ id: 'ECDSA-secp256k1-SHA256',
22
+ algorithm: 'ECDSA',
23
+ curve: 'secp256k1',
24
+ hash: 'SHA-256',
25
+ },
26
+ {
27
+ id: 'ECDSA-secp384r1-SHA256',
28
+ algorithm: 'ECDSA',
29
+ curve: 'secp384r1',
30
+ hash: 'SHA-256',
31
+ },
32
+ {
33
+ id: 'ECDSA-brainpool256r1-SHA256',
34
+ algorithm: 'ECDSA',
35
+ curve: 'brainpool256r1',
36
+ hash: 'SHA-256',
37
+ },
38
+ {
39
+ id: 'ECDSA-brainpool384r1-SHA256',
40
+ algorithm: 'ECDSA',
41
+ curve: 'brainpool384r1',
42
+ hash: 'SHA-256',
43
+ },
44
+ ];
45
+ export function signatureMethodFromId(id) {
46
+ const signatureMethod = signatureMethods.find((signature) => signature.id === id);
47
+ if (!signatureMethod) {
48
+ throw new Error(`Unsupported signature method: ${id}`);
49
+ }
50
+ return signatureMethod;
51
+ }
@@ -0,0 +1,29 @@
1
+ import { signatureMethodFromId } from './signature';
2
+ import { Header } from './types';
3
+ import { CurveMismatchError } from './errors';
4
+ export default class Signer {
5
+ crypto;
6
+ constructor(crypto) {
7
+ this.crypto = crypto;
8
+ }
9
+ async sign(payload, privateKey, signatureMethodId) {
10
+ const signatureMethod = signatureMethodFromId(signatureMethodId);
11
+ if (signatureMethod.curve !== privateKey.getCurve()) {
12
+ throw new CurveMismatchError(`Expected ${privateKey.getCurve()}, actual ${signatureMethod.curve}`);
13
+ }
14
+ const payloadDataSegment = JSON.stringify(payload);
15
+ const textEncoder = new TextEncoder();
16
+ const payloadDataSegmentBytes = textEncoder.encode(payloadDataSegment);
17
+ const signature = await this.crypto.sign(payloadDataSegmentBytes, privateKey, signatureMethod.hash, 'sigvalue-der');
18
+ const signatureSegment = JSON.stringify({
19
+ SA: signatureMethod.id,
20
+ SD: bytesToHex(signature).toUpperCase(),
21
+ });
22
+ return [Header, payloadDataSegment, signatureSegment].join('|');
23
+ }
24
+ }
25
+ function bytesToHex(bytes) {
26
+ return Array.from(bytes)
27
+ .map((x) => x.toString(16).padStart(2, '0'))
28
+ .join('');
29
+ }
@@ -0,0 +1 @@
1
+ export const Header = 'OCMF';
@@ -0,0 +1,44 @@
1
+ import { signatureMethodFromId } from './signature';
2
+ import { decomposeValue, parseSections } from './parse';
3
+ import { CurveMismatchError, UnknownSignatureEncoding, UnknownSignatureMimeType, } from './errors';
4
+ export default class Verifier {
5
+ crypto;
6
+ constructor(crypto) {
7
+ this.crypto = crypto;
8
+ }
9
+ async parseAndVerify(rawSignedData, publicKey) {
10
+ const sections = decomposeValue(rawSignedData);
11
+ const signedData = parseSections(sections);
12
+ const { signature } = signedData;
13
+ const signatureMethod = signatureMethodFromId(signature.SA);
14
+ if (signatureMethod.curve !== publicKey.getCurve()) {
15
+ throw new CurveMismatchError(`Expected ${publicKey.getCurve()}, actual ${signatureMethod.curve}`);
16
+ }
17
+ if (signature.SE && signature.SE !== 'hex') {
18
+ throw new UnknownSignatureEncoding('Only hex encoded signatures are supported');
19
+ }
20
+ if (signature.SM && signature.SM !== 'application/x-der') {
21
+ throw new UnknownSignatureMimeType('Only application/x-der encoded signatures are supported');
22
+ }
23
+ const textEncoder = new TextEncoder();
24
+ const payloadDataSegment = textEncoder.encode(sections.payloadData);
25
+ const signatureBytes = hexToBytes(signature.SD);
26
+ const verified = await this.crypto.verify(signatureBytes, payloadDataSegment, publicKey, signatureMethod.hash, 'sigvalue-der');
27
+ return {
28
+ verified,
29
+ value: verified ? signedData : undefined,
30
+ };
31
+ }
32
+ }
33
+ function hexToBytes(hex) {
34
+ if (hex.length === 0 ||
35
+ hex.length % 2 !== 0 ||
36
+ !hex.match(/^[A-Za-f0-9]+$/)) {
37
+ throw new Error('Invalid hex string');
38
+ }
39
+ const bytes = hex.match(/.{2}/g)?.map((byte) => parseInt(byte, 16));
40
+ if (!bytes) {
41
+ throw new Error('Failed to map hex string');
42
+ }
43
+ return new Uint8Array(bytes);
44
+ }
@@ -0,0 +1,6 @@
1
+ export declare class CurveMismatchError extends Error {
2
+ }
3
+ export declare class UnknownSignatureEncoding extends Error {
4
+ }
5
+ export declare class UnknownSignatureMimeType extends Error {
6
+ }
@@ -0,0 +1,8 @@
1
+ export * from './types';
2
+ export * from './parse';
3
+ export * from './signature';
4
+ export * from './errors';
5
+ export * from './verifier';
6
+ export * from './signer';
7
+ export { default as Signer } from './signer';
8
+ export { default as Verifier } from './verifier';
@@ -0,0 +1,9 @@
1
+ import { Header, SignedData } from './types';
2
+ interface Sections {
3
+ header: Header;
4
+ payloadData: string;
5
+ signature: string;
6
+ }
7
+ export declare function decomposeValue(value: string): Sections;
8
+ export declare function parseSections(sections: Sections): SignedData;
9
+ export {};
@@ -0,0 +1,9 @@
1
+ import { Curve, Hash } from '@road-labs/ocmf-crypto';
2
+ export type SignatureMethodId = 'ECDSA-brainpool256r1-SHA256' | 'ECDSA-brainpool384r1-SHA256' | 'ECDSA-secp192k1-SHA256' | 'ECDSA-secp192r1-SHA256' | 'ECDSA-secp256k1-SHA256' | 'ECDSA-secp256r1-SHA256' | 'ECDSA-secp384r1-SHA256';
3
+ export type SignatureMethod = {
4
+ id: SignatureMethodId;
5
+ algorithm: string;
6
+ curve: Curve;
7
+ hash: Hash;
8
+ };
9
+ export declare function signatureMethodFromId(id: SignatureMethodId): SignatureMethod;
@@ -0,0 +1,13 @@
1
+ import { SignatureMethodId } from './signature';
2
+ import { PayloadData } from './types';
3
+ import { CryptoAdapter, EcPrivateKey } from '@road-labs/ocmf-crypto';
4
+ export default class Signer {
5
+ private readonly crypto;
6
+ constructor(crypto: CryptoAdapter);
7
+ /**
8
+ * @param payload - Meter data payload to be signed
9
+ * @param privateKey - Private key to use for signing
10
+ * @param signatureMethodId - Signing method. Note: the curve indicated by the id must match that of the private key
11
+ */
12
+ sign(payload: PayloadData, privateKey: EcPrivateKey, signatureMethodId: SignatureMethodId): Promise<string>;
13
+ }
@@ -0,0 +1,51 @@
1
+ import { SignatureMethodId } from './signature';
2
+ export interface SignedData {
3
+ header: Header;
4
+ payloadData: PayloadData;
5
+ signature: Signature;
6
+ }
7
+ export type Header = 'OCMF';
8
+ export declare const Header: Header;
9
+ export interface PayloadData {
10
+ FV?: string;
11
+ GI?: string;
12
+ GS?: string;
13
+ GV?: string;
14
+ PG: string;
15
+ MV?: string;
16
+ MM?: string;
17
+ MS: string;
18
+ MF?: string;
19
+ IS: boolean;
20
+ IL?: string;
21
+ IF?: string[];
22
+ IT: string;
23
+ ID?: string;
24
+ TT?: string;
25
+ CF?: string;
26
+ LC?: {
27
+ LN?: string;
28
+ LI?: number;
29
+ LR: number;
30
+ LU: string;
31
+ };
32
+ CT?: string;
33
+ CI?: string;
34
+ RD: {
35
+ TM: string;
36
+ TX?: 'B' | 'C' | 'X' | 'E' | 'L' | 'R' | 'A' | 'P' | 'S' | 'T';
37
+ RV: number;
38
+ RI?: string;
39
+ RU: string;
40
+ RT?: string;
41
+ CL?: number;
42
+ EF?: string;
43
+ ST: string;
44
+ }[];
45
+ }
46
+ export interface Signature {
47
+ SA: SignatureMethodId;
48
+ SE?: 'hex';
49
+ SM?: 'application/x-der';
50
+ SD: string;
51
+ }
@@ -0,0 +1,16 @@
1
+ import { SignedData } from './types';
2
+ import { CryptoAdapter, EcPublicKey } from '@road-labs/ocmf-crypto';
3
+ export interface ParseAndVerifyResult {
4
+ verified: boolean;
5
+ value?: SignedData;
6
+ }
7
+ export default class Verifier {
8
+ private readonly crypto;
9
+ constructor(crypto: CryptoAdapter);
10
+ /**
11
+ * @param rawSignedData - The full OCMF signed meter data payload as sent by the charging station
12
+ * @param publicKey - The public key for verifying the OCMF signed meter data payload
13
+ * @return Result of verification. Note: if the payload was not verified, the parsed value is not included.
14
+ */
15
+ parseAndVerify(rawSignedData: string, publicKey: EcPublicKey): Promise<ParseAndVerifyResult>;
16
+ }
package/jest.config.js ADDED
@@ -0,0 +1,11 @@
1
+ const { createDefaultPreset } = require('ts-jest');
2
+
3
+ const tsJestTransformCfg = createDefaultPreset().transform;
4
+
5
+ /** @type {import("jest").Config} **/
6
+ module.exports = {
7
+ testEnvironment: 'node',
8
+ transform: {
9
+ ...tsJestTransformCfg,
10
+ },
11
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@road-labs/ocmf",
3
+ "version": "0.0.1",
4
+ "main": "build/cjs/index.js",
5
+ "module": "build/es2022/index.js",
6
+ "types": "build/types/index.d.ts",
7
+ "keywords": [],
8
+ "author": "",
9
+ "license": "MIT",
10
+ "description": "",
11
+ "devDependencies": {
12
+ "@jest/globals": "^30.0.0",
13
+ "@types/node": "^22.15.30",
14
+ "jest": "^30.0.0",
15
+ "rimraf": "^6.0.1",
16
+ "ts-jest": "^29.4.0",
17
+ "ts-node": "^10.9.2",
18
+ "typescript": "^5.8.3",
19
+ "jest-mock-extended": "^4.0.0",
20
+ "test-commons": "0.0.1"
21
+ },
22
+ "dependencies": {
23
+ "@road-labs/ocmf-crypto": "0.0.1"
24
+ },
25
+ "scripts": {
26
+ "clear": "rimraf build",
27
+ "build": "pnpm run build:module && pnpm run build:types",
28
+ "build:module": "pnpm run build:cjs && pnpm run build:es2022",
29
+ "build:cjs": "tsc -p tsconfig.json --removeComments --module commonjs --outDir build/cjs",
30
+ "build:es2022": "tsc -p tsconfig.json --removeComments --module es2022 --outDir build/es2022",
31
+ "prebuild:types": "rimraf build/types",
32
+ "build:types": "tsc -p tsconfig.json --outDir build/types --declaration --emitDeclarationOnly",
33
+ "rebuild": "pnpm run clear && pnpm run build",
34
+ "test": "jest"
35
+ }
36
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,3 @@
1
+ export class CurveMismatchError extends Error {}
2
+ export class UnknownSignatureEncoding extends Error {}
3
+ export class UnknownSignatureMimeType extends Error {}
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from './types';
2
+ export * from './parse';
3
+ export * from './signature';
4
+ export * from './errors';
5
+ export * from './verifier';
6
+ export * from './signer';
7
+ export { default as Signer } from './signer';
8
+ export { default as Verifier } from './verifier';
package/src/parse.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { Header, SignedData } from './types';
2
+
3
+ interface Sections {
4
+ header: Header;
5
+ payloadData: string;
6
+ signature: string;
7
+ }
8
+
9
+ export function decomposeValue(value: string): Sections {
10
+ const parts = value.split('|');
11
+ if (parts.length !== 3) {
12
+ throw new Error('Signed data must be in OCMF|data|signature format');
13
+ }
14
+
15
+ const headerSection = parts[0];
16
+ const payloadDataSection = parts[1];
17
+ const signatureSection = parts[2];
18
+
19
+ if (headerSection !== 'OCMF') {
20
+ throw new Error('OCMF header required');
21
+ }
22
+ if (payloadDataSection.length === 0) {
23
+ throw new Error('Payload section cannot be empty');
24
+ }
25
+ if (signatureSection.length === 0) {
26
+ throw new Error('Signature section cannot be empty');
27
+ }
28
+
29
+ return {
30
+ header: headerSection,
31
+ payloadData: payloadDataSection,
32
+ signature: signatureSection,
33
+ };
34
+ }
35
+
36
+ export function parseSections(sections: Sections): SignedData {
37
+ return {
38
+ header: sections.header,
39
+ payloadData: JSON.parse(sections.payloadData),
40
+ signature: JSON.parse(sections.signature),
41
+ };
42
+ }
@@ -0,0 +1,72 @@
1
+ import { Curve, Hash } from '@road-labs/ocmf-crypto';
2
+
3
+ export type SignatureMethodId =
4
+ | 'ECDSA-brainpool256r1-SHA256'
5
+ | 'ECDSA-brainpool384r1-SHA256'
6
+ | 'ECDSA-secp192k1-SHA256'
7
+ | 'ECDSA-secp192r1-SHA256'
8
+ | 'ECDSA-secp256k1-SHA256'
9
+ | 'ECDSA-secp256r1-SHA256'
10
+ | 'ECDSA-secp384r1-SHA256';
11
+
12
+ export type SignatureMethod = {
13
+ id: SignatureMethodId;
14
+ algorithm: string;
15
+ curve: Curve;
16
+ hash: Hash;
17
+ };
18
+
19
+ const signatureMethods: SignatureMethod[] = [
20
+ {
21
+ id: 'ECDSA-secp192r1-SHA256',
22
+ algorithm: 'ECDSA',
23
+ curve: 'secp192r1',
24
+ hash: 'SHA-256',
25
+ },
26
+ {
27
+ id: 'ECDSA-secp192k1-SHA256',
28
+ algorithm: 'ECDSA',
29
+ curve: 'secp192k1',
30
+ hash: 'SHA-256',
31
+ },
32
+ {
33
+ id: 'ECDSA-secp256r1-SHA256',
34
+ algorithm: 'ECDSA',
35
+ curve: 'secp256r1',
36
+ hash: 'SHA-256',
37
+ },
38
+ {
39
+ id: 'ECDSA-secp256k1-SHA256',
40
+ algorithm: 'ECDSA',
41
+ curve: 'secp256k1',
42
+ hash: 'SHA-256',
43
+ },
44
+ {
45
+ id: 'ECDSA-secp384r1-SHA256',
46
+ algorithm: 'ECDSA',
47
+ curve: 'secp384r1',
48
+ hash: 'SHA-256',
49
+ },
50
+ {
51
+ id: 'ECDSA-brainpool256r1-SHA256',
52
+ algorithm: 'ECDSA',
53
+ curve: 'brainpool256r1',
54
+ hash: 'SHA-256',
55
+ },
56
+ {
57
+ id: 'ECDSA-brainpool384r1-SHA256',
58
+ algorithm: 'ECDSA',
59
+ curve: 'brainpool384r1',
60
+ hash: 'SHA-256',
61
+ },
62
+ ];
63
+
64
+ export function signatureMethodFromId(id: SignatureMethodId): SignatureMethod {
65
+ const signatureMethod = signatureMethods.find(
66
+ (signature) => signature.id === id
67
+ );
68
+ if (!signatureMethod) {
69
+ throw new Error(`Unsupported signature method: ${id}`);
70
+ }
71
+ return signatureMethod;
72
+ }
package/src/signer.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { signatureMethodFromId, SignatureMethodId } from './signature';
2
+ import { Header, PayloadData, Signature } from './types';
3
+ import { CryptoAdapter, EcPrivateKey } from '@road-labs/ocmf-crypto';
4
+ import { CurveMismatchError } from './errors';
5
+
6
+ export default class Signer {
7
+ constructor(private readonly crypto: CryptoAdapter) {}
8
+
9
+ /**
10
+ * @param payload - Meter data payload to be signed
11
+ * @param privateKey - Private key to use for signing
12
+ * @param signatureMethodId - Signing method. Note: the curve indicated by the id must match that of the private key
13
+ */
14
+ public async sign(
15
+ payload: PayloadData,
16
+ privateKey: EcPrivateKey,
17
+ signatureMethodId: SignatureMethodId
18
+ ): Promise<string> {
19
+ const signatureMethod = signatureMethodFromId(signatureMethodId);
20
+ if (signatureMethod.curve !== privateKey.getCurve()) {
21
+ throw new CurveMismatchError(
22
+ `Expected ${privateKey.getCurve()}, actual ${signatureMethod.curve}`
23
+ );
24
+ }
25
+
26
+ const payloadDataSegment = JSON.stringify(payload);
27
+ const textEncoder = new TextEncoder();
28
+ const payloadDataSegmentBytes = textEncoder.encode(payloadDataSegment);
29
+
30
+ const signature = await this.crypto.sign(
31
+ payloadDataSegmentBytes,
32
+ privateKey,
33
+ signatureMethod.hash,
34
+ 'sigvalue-der'
35
+ );
36
+
37
+ const signatureSegment = JSON.stringify({
38
+ SA: signatureMethod.id,
39
+ SD: bytesToHex(signature).toUpperCase(),
40
+ } as Signature);
41
+
42
+ return [Header, payloadDataSegment, signatureSegment].join('|');
43
+ }
44
+ }
45
+
46
+ function bytesToHex(bytes: Uint8Array): string {
47
+ return Array.from(bytes)
48
+ .map((x) => x.toString(16).padStart(2, '0'))
49
+ .join('');
50
+ }
package/src/types.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { SignatureMethodId } from './signature';
2
+
3
+ export interface SignedData {
4
+ header: Header;
5
+ payloadData: PayloadData;
6
+ signature: Signature;
7
+ }
8
+
9
+ export type Header = 'OCMF';
10
+ export const Header: Header = 'OCMF';
11
+
12
+ export interface PayloadData {
13
+ FV?: string;
14
+ GI?: string;
15
+ GS?: string;
16
+ GV?: string;
17
+ PG: string;
18
+ MV?: string;
19
+ MM?: string;
20
+ MS: string;
21
+ MF?: string;
22
+ IS: boolean;
23
+ IL?: string;
24
+ IF?: string[];
25
+ IT: string;
26
+ ID?: string;
27
+ TT?: string;
28
+ CF?: string;
29
+ LC?: {
30
+ LN?: string;
31
+ LI?: number;
32
+ LR: number;
33
+ LU: string;
34
+ };
35
+ CT?: string;
36
+ CI?: string;
37
+ RD: {
38
+ TM: string;
39
+ TX?: 'B' | 'C' | 'X' | 'E' | 'L' | 'R' | 'A' | 'P' | 'S' | 'T';
40
+ RV: number;
41
+ RI?: string;
42
+ RU: string;
43
+ RT?: string;
44
+ CL?: number;
45
+ EF?: string;
46
+ ST: string;
47
+ }[];
48
+ }
49
+
50
+ export interface Signature {
51
+ SA: SignatureMethodId;
52
+ SE?: 'hex';
53
+ SM?: 'application/x-der';
54
+ SD: string;
55
+ }
@@ -0,0 +1,83 @@
1
+ import { SignedData } from './types';
2
+ import { signatureMethodFromId } from './signature';
3
+ import { decomposeValue, parseSections } from './parse';
4
+ import { CryptoAdapter, EcPublicKey } from '@road-labs/ocmf-crypto';
5
+ import {
6
+ CurveMismatchError,
7
+ UnknownSignatureEncoding,
8
+ UnknownSignatureMimeType,
9
+ } from './errors';
10
+
11
+ export interface ParseAndVerifyResult {
12
+ verified: boolean;
13
+ value?: SignedData; // Only included if the payload was verified
14
+ }
15
+
16
+ export default class Verifier {
17
+ constructor(private readonly crypto: CryptoAdapter) {}
18
+
19
+ /**
20
+ * @param rawSignedData - The full OCMF signed meter data payload as sent by the charging station
21
+ * @param publicKey - The public key for verifying the OCMF signed meter data payload
22
+ * @return Result of verification. Note: if the payload was not verified, the parsed value is not included.
23
+ */
24
+ async parseAndVerify(
25
+ rawSignedData: string,
26
+ publicKey: EcPublicKey
27
+ ): Promise<ParseAndVerifyResult> {
28
+ const sections = decomposeValue(rawSignedData);
29
+ const signedData = parseSections(sections);
30
+ const { signature } = signedData;
31
+
32
+ const signatureMethod = signatureMethodFromId(signature.SA);
33
+ if (signatureMethod.curve !== publicKey.getCurve()) {
34
+ throw new CurveMismatchError(
35
+ `Expected ${publicKey.getCurve()}, actual ${signatureMethod.curve}`
36
+ );
37
+ }
38
+
39
+ if (signature.SE && signature.SE !== 'hex') {
40
+ throw new UnknownSignatureEncoding(
41
+ 'Only hex encoded signatures are supported'
42
+ );
43
+ }
44
+
45
+ if (signature.SM && signature.SM !== 'application/x-der') {
46
+ throw new UnknownSignatureMimeType(
47
+ 'Only application/x-der encoded signatures are supported'
48
+ );
49
+ }
50
+
51
+ const textEncoder = new TextEncoder();
52
+ const payloadDataSegment = textEncoder.encode(sections.payloadData);
53
+ const signatureBytes = hexToBytes(signature.SD);
54
+
55
+ const verified = await this.crypto.verify(
56
+ signatureBytes,
57
+ payloadDataSegment,
58
+ publicKey,
59
+ signatureMethod.hash,
60
+ 'sigvalue-der'
61
+ );
62
+
63
+ return {
64
+ verified,
65
+ value: verified ? signedData : undefined,
66
+ };
67
+ }
68
+ }
69
+
70
+ function hexToBytes(hex: string): Uint8Array {
71
+ if (
72
+ hex.length === 0 ||
73
+ hex.length % 2 !== 0 ||
74
+ !hex.match(/^[A-Za-f0-9]+$/)
75
+ ) {
76
+ throw new Error('Invalid hex string');
77
+ }
78
+ const bytes = hex.match(/.{2}/g)?.map((byte) => parseInt(byte, 16));
79
+ if (!bytes) {
80
+ throw new Error('Failed to map hex string');
81
+ }
82
+ return new Uint8Array(bytes);
83
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from '@jest/globals';
2
+ import { decomposeValue, parseSections } from '../src';
3
+
4
+ describe('decomposeValue', () => {
5
+ it('returns decomposed sections for a valid value', () => {
6
+ const { header, payloadData, signature } = decomposeValue(
7
+ 'OCMF|data|signature'
8
+ );
9
+ expect(header).toEqual('OCMF');
10
+ expect(payloadData).toEqual('data');
11
+ expect(signature).toEqual('signature');
12
+ });
13
+
14
+ it.each([
15
+ {
16
+ name: 'too few segments',
17
+ value: 'OCMF|data',
18
+ },
19
+ {
20
+ name: 'wrong header segment',
21
+ value: 'FOO|data|signature',
22
+ },
23
+ {
24
+ name: 'empty data payload segment',
25
+ value: 'OCMF||signature',
26
+ },
27
+ {
28
+ name: 'empty signature segment',
29
+ value: 'OCMF|data|',
30
+ },
31
+ ])('throws when $name', ({ value }) => {
32
+ expect(() => decomposeValue(value)).toThrow();
33
+ });
34
+ });
35
+
36
+ describe('parseSections', () => {
37
+ it('parses the sections', () => {
38
+ const { header, payloadData, signature } = parseSections({
39
+ header: 'OCMF',
40
+ payloadData: '{"FV":"1.0"}',
41
+ signature: '{"SD":"ABC"}',
42
+ });
43
+ expect(header).toEqual('OCMF');
44
+ expect(payloadData).toEqual({ FV: '1.0' });
45
+ expect(signature).toEqual({ SD: 'ABC' });
46
+ });
47
+
48
+ it('throws on invalid json in the data section', () => {
49
+ expect(() =>
50
+ parseSections({
51
+ header: 'OCMF',
52
+ payloadData: '{',
53
+ signature: '{"SD":"ABC"}',
54
+ })
55
+ ).toThrow();
56
+ });
57
+
58
+ it('throws on invalid json in the signature section', () => {
59
+ expect(() =>
60
+ parseSections({
61
+ header: 'OCMF',
62
+ payloadData: '{"FV":"1.0"}',
63
+ signature: '{',
64
+ })
65
+ ).toThrow();
66
+ });
67
+ });
@@ -0,0 +1,81 @@
1
+ import { beforeEach, describe, expect, it } from '@jest/globals';
2
+ import { CryptoAdapter, EcPrivateKey } from '@road-labs/ocmf-crypto';
3
+ import { mock, MockProxy } from 'jest-mock-extended';
4
+ import { CurveMismatchError, PayloadData, Signer } from '../src';
5
+ import { hexToBytes } from 'test-commons';
6
+
7
+ const payload: PayloadData = {
8
+ FV: '1.0',
9
+ GI: 'TEST',
10
+ GS: '1234567',
11
+ GV: '1.0.0',
12
+ PG: 'T1',
13
+ MV: 'MV',
14
+ MM: 'MM',
15
+ MS: '1234567',
16
+ MF: '1.0',
17
+ IS: true,
18
+ IL: 'VERIFIED',
19
+ IF: ['RFID_PLAIN', 'OCPP_RS_TLS'],
20
+ IT: 'ISO14443',
21
+ ID: '79A94A26862469',
22
+ CI: 'CSONE',
23
+ CT: 'CBIDC',
24
+ RD: [
25
+ {
26
+ TM: '2025-06-14T08:44:54,562+0100 S',
27
+ TX: 'B',
28
+ RV: 0.75727,
29
+ RI: '1-b',
30
+ RU: 'kWh',
31
+ ST: 'G',
32
+ },
33
+ {
34
+ TM: '2025-06-14T11:44:54,562+0100 S',
35
+ TX: 'E',
36
+ RV: 3.12961,
37
+ RI: '1-b',
38
+ RU: 'kWh',
39
+ ST: 'G',
40
+ },
41
+ ],
42
+ };
43
+ const hexPayloadData =
44
+ '7b224656223a22312e30222c224749223a2254455354222c224753223a2231323334353637222c224756223a22312e302e30222c225047223a225431222c224d56223a224d56222c224d4d223a224d4d222c224d53223a2231323334353637222c224d46223a22312e30222c224953223a747275652c22494c223a225645524946494544222c224946223a5b22524649445f504c41494e222c224f4350505f52535f544c53225d2c224954223a2249534f3134343433222c224944223a223739413934413236383632343639222c224349223a2243534f4e45222c224354223a224342494443222c225244223a5b7b22544d223a22323032352d30362d31345430383a34343a35342c3536322b303130302053222c225458223a2242222c225256223a302e37353732372c225249223a22312d62222c225255223a226b5768222c225354223a2247227d2c7b22544d223a22323032352d30362d31345431313a34343a35342c3536322b303130302053222c225458223a2245222c225256223a332e31323936312c225249223a22312d62222c225255223a226b5768222c225354223a2247227d5d7d';
45
+
46
+ describe('Signer', () => {
47
+ let privateKey: MockProxy<EcPrivateKey>;
48
+ let crypto: MockProxy<CryptoAdapter>;
49
+ let signer: Signer;
50
+
51
+ beforeEach(() => {
52
+ privateKey = mock<EcPrivateKey>();
53
+ crypto = mock<CryptoAdapter>();
54
+ signer = new Signer(crypto);
55
+ });
56
+
57
+ it('throws if the private key curve does not align with the signature', async () => {
58
+ privateKey.getCurve.mockReturnValue('secp192r1');
59
+ await expect(() =>
60
+ signer.sign(payload, privateKey, 'ECDSA-secp256r1-SHA256')
61
+ ).rejects.toThrow(CurveMismatchError);
62
+ });
63
+
64
+ it('signs via the crypto adapter and returns a composed value', async () => {
65
+ privateKey.getCurve.mockReturnValue('secp256r1');
66
+ crypto.sign.mockReturnValue(new Uint8Array([0x01, 0x02, 0x03, 0x0a]));
67
+ const actual = await signer.sign(
68
+ payload,
69
+ privateKey,
70
+ 'ECDSA-secp256r1-SHA256'
71
+ );
72
+ const expected = `OCMF|{"FV":"1.0","GI":"TEST","GS":"1234567","GV":"1.0.0","PG":"T1","MV":"MV","MM":"MM","MS":"1234567","MF":"1.0","IS":true,"IL":"VERIFIED","IF":["RFID_PLAIN","OCPP_RS_TLS"],"IT":"ISO14443","ID":"79A94A26862469","CI":"CSONE","CT":"CBIDC","RD":[{"TM":"2025-06-14T08:44:54,562+0100 S","TX":"B","RV":0.75727,"RI":"1-b","RU":"kWh","ST":"G"},{"TM":"2025-06-14T11:44:54,562+0100 S","TX":"E","RV":3.12961,"RI":"1-b","RU":"kWh","ST":"G"}]}|{"SA":"ECDSA-secp256r1-SHA256","SD":"0102030A"}`;
73
+ expect(actual).toEqual(expected);
74
+ expect(crypto.sign).toHaveBeenCalledWith(
75
+ hexToBytes(hexPayloadData),
76
+ privateKey,
77
+ 'SHA-256',
78
+ 'sigvalue-der'
79
+ );
80
+ });
81
+ });
@@ -0,0 +1,111 @@
1
+ import { beforeEach, describe, expect, it } from '@jest/globals';
2
+ import { CryptoAdapter, EcPublicKey } from '@road-labs/ocmf-crypto';
3
+ import { mock, MockProxy } from 'jest-mock-extended';
4
+ import {
5
+ CurveMismatchError,
6
+ UnknownSignatureEncoding,
7
+ UnknownSignatureMimeType,
8
+ Verifier,
9
+ } from '../src';
10
+ import { hexToBytes } from 'test-commons';
11
+
12
+ const signedData = `OCMF|{"FV":"1.0","GI":"TEST","GS":"1234567","GV":"1.0.0","PG":"T1","MV":"MV","MM":"MM","MS":"1234567","MF":"1.0","IS":true,"IL":"VERIFIED","IF":["RFID_PLAIN","OCPP_RS_TLS"],"IT":"ISO14443","ID":"79A94A26862469","CI":"CSONE","CT":"CBIDC","RD":[{"TM":"2025-06-14T08:44:54,562+0100 S","TX":"B","RV":0.75727,"RI":"1-b","RU":"kWh","ST":"G"},{"TM":"2025-06-14T11:44:54,562+0100 S","TX":"E","RV":3.12961,"RI":"1-b","RU":"kWh","ST":"G"}]}|{"SA":"ECDSA-secp256r1-SHA256","SD":"0102030A"}`;
13
+ const hexPayloadData =
14
+ '7b224656223a22312e30222c224749223a2254455354222c224753223a2231323334353637222c224756223a22312e302e30222c225047223a225431222c224d56223a224d56222c224d4d223a224d4d222c224d53223a2231323334353637222c224d46223a22312e30222c224953223a747275652c22494c223a225645524946494544222c224946223a5b22524649445f504c41494e222c224f4350505f52535f544c53225d2c224954223a2249534f3134343433222c224944223a223739413934413236383632343639222c224349223a2243534f4e45222c224354223a224342494443222c225244223a5b7b22544d223a22323032352d30362d31345430383a34343a35342c3536322b303130302053222c225458223a2242222c225256223a302e37353732372c225249223a22312d62222c225255223a226b5768222c225354223a2247227d2c7b22544d223a22323032352d30362d31345431313a34343a35342c3536322b303130302053222c225458223a2245222c225256223a332e31323936312c225249223a22312d62222c225255223a226b5768222c225354223a2247227d5d7d';
15
+
16
+ describe('Verifier', () => {
17
+ let publicKey: MockProxy<EcPublicKey>;
18
+ let crypto: MockProxy<CryptoAdapter>;
19
+ let verifier: Verifier;
20
+
21
+ beforeEach(() => {
22
+ publicKey = mock<EcPublicKey>();
23
+ crypto = mock<CryptoAdapter>();
24
+ verifier = new Verifier(crypto);
25
+ });
26
+
27
+ it('throws if the private key curve does not align with the signature', async () => {
28
+ publicKey.getCurve.mockReturnValue('secp192r1');
29
+ await expect(() =>
30
+ verifier.parseAndVerify(signedData, publicKey)
31
+ ).rejects.toThrow(CurveMismatchError);
32
+ });
33
+
34
+ it('throws if the signature is encoded with an unknown type', async () => {
35
+ publicKey.getCurve.mockReturnValue('secp256r1');
36
+ const signedData = `OCMF|{"FV":"1.0"}|{"SE":"foo","SA":"ECDSA-secp256r1-SHA256","SD":"0102030A"}`;
37
+
38
+ await expect(() =>
39
+ verifier.parseAndVerify(signedData, publicKey)
40
+ ).rejects.toThrow(UnknownSignatureEncoding);
41
+ });
42
+
43
+ it('throws if the signature is encoded with an unknown mime type', async () => {
44
+ publicKey.getCurve.mockReturnValue('secp256r1');
45
+ const signedData = `OCMF|{"FV":"1.0"}|{"SM":"foo","SA":"ECDSA-secp256r1-SHA256","SD":"0102030A"}`;
46
+
47
+ await expect(() =>
48
+ verifier.parseAndVerify(signedData, publicKey)
49
+ ).rejects.toThrow(UnknownSignatureMimeType);
50
+ });
51
+
52
+ it('signs via the crypto adapter and returns a composed value', async () => {
53
+ publicKey.getCurve.mockReturnValue('secp256r1');
54
+ crypto.verify.mockReturnValue(true);
55
+ const actual = await verifier.parseAndVerify(signedData, publicKey);
56
+ const expected = {
57
+ verified: true,
58
+ value: {
59
+ header: 'OCMF',
60
+ payloadData: {
61
+ FV: '1.0',
62
+ GI: 'TEST',
63
+ GS: '1234567',
64
+ GV: '1.0.0',
65
+ PG: 'T1',
66
+ MV: 'MV',
67
+ MM: 'MM',
68
+ MS: '1234567',
69
+ MF: '1.0',
70
+ IS: true,
71
+ IL: 'VERIFIED',
72
+ IF: ['RFID_PLAIN', 'OCPP_RS_TLS'],
73
+ IT: 'ISO14443',
74
+ ID: '79A94A26862469',
75
+ CI: 'CSONE',
76
+ CT: 'CBIDC',
77
+ RD: [
78
+ {
79
+ TM: '2025-06-14T08:44:54,562+0100 S',
80
+ TX: 'B',
81
+ RV: 0.75727,
82
+ RI: '1-b',
83
+ RU: 'kWh',
84
+ ST: 'G',
85
+ },
86
+ {
87
+ TM: '2025-06-14T11:44:54,562+0100 S',
88
+ TX: 'E',
89
+ RV: 3.12961,
90
+ RI: '1-b',
91
+ RU: 'kWh',
92
+ ST: 'G',
93
+ },
94
+ ],
95
+ },
96
+ signature: {
97
+ SA: 'ECDSA-secp256r1-SHA256',
98
+ SD: '0102030A',
99
+ },
100
+ },
101
+ };
102
+ expect(actual).toEqual(expected);
103
+ expect(crypto.verify).toHaveBeenCalledWith(
104
+ hexToBytes('0102030A'),
105
+ hexToBytes(hexPayloadData),
106
+ publicKey,
107
+ 'SHA-256',
108
+ 'sigvalue-der'
109
+ );
110
+ });
111
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*"]
4
+ }