From 917f609f06f3861df5cb1842d7078a14fecbb079 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bla=C5=BE=20Dular?= <22869613+xBlaz3kx@users.noreply.github.com> Date: Tue, 17 Dec 2024 22:19:55 +0100 Subject: [PATCH] Feature: Parser implementation (#3) * Added parser struct and opts for parser * Parser improvements and added tests --- builder_test.go | 6 -- parser.go | 120 ++++++++++++++++++++++++++ parser_opts.go | 30 +++++++ parser_opts_test.go | 102 +++++++++++++++++++++++ parser_test.go | 199 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 451 insertions(+), 6 deletions(-) create mode 100644 parser.go create mode 100644 parser_opts.go create mode 100644 parser_opts_test.go create mode 100644 parser_test.go diff --git a/builder_test.go b/builder_test.go index 459ae8a..0fc5608 100644 --- a/builder_test.go +++ b/builder_test.go @@ -13,12 +13,6 @@ type builderTestSuite struct { suite.Suite } -func (s *builderTestSuite) SetupTest() { -} - -func (s *builderTestSuite) TearDownSuite() { -} - func (s *builderTestSuite) TestNewBuilder() { tests := []struct { name string diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..7884ddf --- /dev/null +++ b/parser.go @@ -0,0 +1,120 @@ +package ocmf_go + +import ( + "encoding/json" + "strings" + + "github.com/pkg/errors" +) + +var ( + ErrInvalidFormat = errors.New("invalid OCMF message format") + ErrVerificationFailure = errors.New("verification failed") + ErrPayloadEmpty = errors.New("payload is empty") +) + +type Parser struct { + payload *PayloadSection + signature *Signature + opts ParserOpts + err error +} + +func NewParser(opts ...Opt) *Parser { + defaults := defaultOpts() + // Apply opts + for _, opt := range opts { + opt(&defaults) + } + + return &Parser{ + opts: defaults, + } +} + +// ParseOcmfMessageFromString Returns a new Parser instance with the payload and signature fields set +func (p *Parser) ParseOcmfMessageFromString(data string) *Parser { + payloadSection, signature, err := parseOcmfMessageFromString(data) + if err != nil { + return &Parser{err: err, opts: p.opts} + } + + return &Parser{ + payload: payloadSection, + signature: signature, + opts: p.opts, + } +} + +func (p *Parser) GetPayload() (*PayloadSection, error) { + if p.err != nil { + return nil, p.err + } + + // Validate the payload if automatic validation is enabled + if p.opts.withAutomaticValidation { + if err := p.payload.Validate(); err != nil { + return nil, errors.Wrap(err, "payload validation failed") + } + } + + return p.payload, nil +} + +func (p *Parser) GetSignature() (*Signature, error) { + if p.err != nil { + return nil, p.err + } + + // Validate the signature if automatic validation is enabled + if p.opts.withAutomaticValidation { + if err := p.signature.Validate(); err != nil { + return nil, errors.Wrap(err, "signature validation failed") + } + } + + if p.opts.withAutomaticSignatureVerification { + if p.payload == nil { + return nil, ErrPayloadEmpty + } + + valid, err := p.signature.Verify(*p.payload, p.opts.publicKey) + if err != nil { + return nil, errors.Wrap(err, "unable to verify signature") + } + + // Even if the signature is valid, we still return an error if the verification failed + if !valid { + return p.signature, ErrVerificationFailure + } + } + + return p.signature, nil +} + +func parseOcmfMessageFromString(data string) (*PayloadSection, *Signature, error) { + if !strings.HasPrefix(data, "OCMF|") { + return nil, nil, ErrInvalidFormat + } + + data, _ = strings.CutPrefix(data, "OCMF|") + splitData := strings.Split(data, "|") + + if len(splitData) != 2 { + return nil, nil, ErrInvalidFormat + } + + payloadSection := PayloadSection{} + err := json.Unmarshal([]byte(splitData[0]), &payloadSection) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to unmarshal payload") + } + + signature := Signature{} + err = json.Unmarshal([]byte(splitData[1]), &signature) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to unmarshal signature") + } + + return &payloadSection, &signature, nil +} diff --git a/parser_opts.go b/parser_opts.go new file mode 100644 index 0000000..e500f3a --- /dev/null +++ b/parser_opts.go @@ -0,0 +1,30 @@ +package ocmf_go + +import "crypto/ecdsa" + +type ParserOpts struct { + withAutomaticValidation bool + withAutomaticSignatureVerification bool + publicKey *ecdsa.PublicKey +} + +type Opt func(*ParserOpts) + +func WithAutomaticValidation() Opt { + return func(p *ParserOpts) { + p.withAutomaticValidation = true + } +} + +func WithAutomaticSignatureVerification(publicKey *ecdsa.PublicKey) Opt { + return func(p *ParserOpts) { + p.withAutomaticSignatureVerification = true + p.publicKey = publicKey + } +} + +func defaultOpts() ParserOpts { + return ParserOpts{ + withAutomaticValidation: false, + } +} diff --git a/parser_opts_test.go b/parser_opts_test.go new file mode 100644 index 0000000..bbcc1f7 --- /dev/null +++ b/parser_opts_test.go @@ -0,0 +1,102 @@ +package ocmf_go + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + + "github.com/stretchr/testify/suite" +) + +type parserOptsTestSuite struct { + suite.Suite +} + +func (s *parserOptsTestSuite) TestParserOptions() { + curve := elliptic.P256() + privateKey, err := ecdsa.GenerateKey(curve, rand.Reader) + s.Require().NoError(err) + + tests := []struct { + name string + opts []Opt + expectedOptions ParserOpts + }{ + { + name: "Default options", + opts: []Opt{}, + expectedOptions: ParserOpts{ + withAutomaticValidation: false, + withAutomaticSignatureVerification: false, + publicKey: nil, + }, + }, + { + name: "With automatic validation", + opts: []Opt{ + WithAutomaticValidation(), + }, + expectedOptions: ParserOpts{ + withAutomaticValidation: true, + withAutomaticSignatureVerification: false, + publicKey: nil, + }, + }, + { + name: "With automatic signature verification but public key is empty", + opts: []Opt{ + WithAutomaticSignatureVerification(nil), + }, + expectedOptions: ParserOpts{ + withAutomaticValidation: false, + withAutomaticSignatureVerification: true, + publicKey: nil, + }, + }, + { + name: "With automatic signature verification", + opts: []Opt{ + WithAutomaticSignatureVerification(&privateKey.PublicKey), + }, + expectedOptions: ParserOpts{ + withAutomaticValidation: false, + withAutomaticSignatureVerification: true, + publicKey: &privateKey.PublicKey, + }, + }, + { + name: "With automatic validation and signature verification", + opts: []Opt{ + WithAutomaticValidation(), + WithAutomaticSignatureVerification(&privateKey.PublicKey), + }, + expectedOptions: ParserOpts{ + withAutomaticValidation: true, + withAutomaticSignatureVerification: true, + publicKey: &privateKey.PublicKey, + }, + }, + } + + for _, tt := range tests { + s.T().Run(tt.name, func(t *testing.T) { + parser := NewParser(tt.opts...) + s.Equal(tt.expectedOptions, parser.opts) + }) + } +} + +func (s *parserOptsTestSuite) TestParserDefaultOptions() { + opts := defaultOpts() + expectedDefaults := ParserOpts{ + withAutomaticValidation: false, + withAutomaticSignatureVerification: false, + publicKey: nil, + } + s.Equal(expectedDefaults, opts) +} + +func TestParserOpts(t *testing.T) { + suite.Run(t, new(parserOptsTestSuite)) +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..5499542 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,199 @@ +package ocmf_go + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "strings" + "testing" + + "github.com/stretchr/testify/suite" +) + +var examplePayload = "OCMF|{\n \"FV\": \"1.0\",\n \"GI\": \"ABL SBC-301\",\n \"GS\": \"808829900001\",\n \"GV\": \"1.4p3\",\n \"PG\": \"T12345\",\n \"MV\": \"Phoenix Contact\",\n \"MM\": \"EEM-350-D-MCB\",\n \"MS\": \"BQ27400330016\",\n \"MF\": \"1.0\",\n \"IS\": true,\n \"IL\": \"VERIFIED\",\n \"IF\": [\n \"RFID_PLAIN\",\n \"OCPP_RS_TLS\"\n ],\n \"IT\": \"ISO14443\",\n \"ID\": \"1F2D3A4F5506C7\",\n \"TT\": \"Tarif 1\",\n \"LC\": {\n \"LN\": \"cable_name\",\n \"LI\": 1,\n \"LR\": 2,\n \"LU\": \"mOhm\"\n },\n \"RD\": [\n {\n \"TM\": \"2018-07-24T13:22:04,000+0200 S\",\n \"TX\": \"B\",\n \"RV\": 2935.6,\n \"RI\": \"1-b:1.8.0\",\n \"RU\": \"kWh\",\n \"RT\": \"DC\",\n \"EF\": \"\",\n \"ST\": \"G\"\n },\n {\n \"TM\": \"2018-07-24T13:26:04,000+0200 S\",\n \"TX\": \"E\",\n \"RV\": 2965.1,\n \"CL\": 0.5,\n \"RI\": \"1-b:1.8.0\",\n \"RU\": \"kWh\",\n \"RT\": \"DC\",\n \"EF\": \"\",\n \"ST\": \"G\"\n }\n ]\n}|{\n \"SD\": \"887FABF407AC82782EEFFF2220C2F856AEB0BC22364BBCC6B55761911ED651D1A922BADA88818C9671AFEE7094D7F536\"\n}" + +type parserTestSuite struct { + suite.Suite +} + +func (s *parserTestSuite) TestParseOcmfMessageFromString_valid() { + payload, signature, err := parseOcmfMessageFromString(examplePayload) + s.NoError(err) + s.NotNil(payload) + s.NotNil(signature) +} + +func (s *parserTestSuite) TestParseOcmfMessageFromString_invalid_format() { + payload, signature, err := parseOcmfMessageFromString("OCMF|{}|{data}") + s.ErrorContains(err, "failed to unmarshal signature") + s.Nil(payload) + s.Nil(signature) + + payloadWithoutOCMF := strings.Replace(examplePayload, "OCMF|", "", 1) + payload, signature, err = parseOcmfMessageFromString(payloadWithoutOCMF) + s.ErrorIs(err, ErrInvalidFormat) + s.Nil(payload) + s.Nil(signature) + + malformedJsonPayload := strings.Replace(examplePayload, "}", "", 1) + payload, signature, err = parseOcmfMessageFromString(malformedJsonPayload) + s.ErrorContains(err, "failed to unmarshal payload") + s.Nil(payload) + s.Nil(signature) +} + +func (s *parserTestSuite) TestGetPayload_valid() { + parser := NewParser().ParseOcmfMessageFromString(examplePayload) + + payload, err := parser.GetPayload() + s.NoError(err) + s.NotNil(payload) + + s.Equal("EEM-350-D-MCB", payload.MeterModel) + s.Equal("BQ27400330016", payload.MeterSerial) +} + +func (s *parserTestSuite) TestGetPayload_unparsable() { + malformedPayload := "OCMF|{}|{" + parser := NewParser().ParseOcmfMessageFromString(malformedPayload) + + payload, err := parser.GetPayload() + s.ErrorContains(err, "failed to unmarshal signature") + s.Nil(payload) +} + +func (s *parserTestSuite) TestGetSignature_valid() { + // Generate private and public ECDSA keys + curve := elliptic.P256() + privateKey, err := ecdsa.GenerateKey(curve, rand.Reader) + s.Require().NoError(err) + + builder := NewBuilder(privateKey). + WithPagination("1"). + WithMeterSerial("exampleSerial123"). + WithIdentificationStatus(true). + WithIdentificationType(string(RfidNone)). + AddReading(Reading{ + Time: "2018-07-24T13:22:04,000+0200 S", + ReadingValue: 1.0, + ReadingUnit: string(UnitskWh), + Status: string(MeterOk), + }) + + message, err := builder.Build() + s.Require().NoError(err) + + tests := []struct { + name string + parserOpts []Opt + data string + expectedSignature *Signature + }{ + { + name: "No validation", + parserOpts: []Opt{}, + data: *message, + expectedSignature: &builder.signature, + }, + { + name: "With automatic signature verification", + parserOpts: []Opt{ + WithAutomaticSignatureVerification(&privateKey.PublicKey), + }, + data: *message, + expectedSignature: &builder.signature, + }, + { + name: "With automatic payload validation", + parserOpts: []Opt{ + WithAutomaticValidation(), + }, + data: *message, + expectedSignature: &builder.signature, + }, + } + + for _, tt := range tests { + s.T().Run(tt.name, func(t *testing.T) { + parser := NewParser(tt.parserOpts...).ParseOcmfMessageFromString(tt.data) + + signature, err := parser.GetSignature() + s.NoError(err) + s.NotNil(signature) + s.Equal(*tt.expectedSignature, *signature) + }) + } +} + +func (s *parserTestSuite) TestGetSignature_invalid() { + // Generate private and public ECDSA keys + curve := elliptic.P256() + privateKey, err := ecdsa.GenerateKey(curve, rand.Reader) + s.Require().NoError(err) + + builder := NewBuilder(privateKey). + WithPagination("1"). + WithMeterSerial("exampleSerial123"). + WithIdentificationStatus(true). + WithIdentificationType(string(RfidNone)). + AddReading(Reading{ + Time: "2018-07-24T13:22:04,000+0200 S", + ReadingValue: 1.0, + ReadingUnit: string(UnitskWh), + Status: string(MeterOk), + }) + + message, err := builder.Build() + s.Require().NoError(err) + + privateKey2, err := ecdsa.GenerateKey(curve, rand.Reader) + s.Require().NoError(err) + + tests := []struct { + name string + parserOpts []Opt + data string + error string + }{ + { + name: "Signature validation failed", + parserOpts: []Opt{ + WithAutomaticSignatureVerification(&privateKey2.PublicKey), + }, + data: *message, + error: "verification failed", + }, { + name: "Nil public key", + parserOpts: []Opt{ + WithAutomaticSignatureVerification(nil), + }, + data: *message, + error: "unable to verify signature", + }, + { + name: "Payload empty", + parserOpts: []Opt{ + WithAutomaticSignatureVerification(&privateKey.PublicKey), + }, + data: *message, + error: "payload is empty", + }, + } + + for _, tt := range tests { + s.T().Run(tt.name, func(t *testing.T) { + parser := NewParser(tt.parserOpts...).ParseOcmfMessageFromString(tt.data) + + if tt.name == "Payload empty" { + parser.payload = nil + } + + _, err := parser.GetSignature() + s.ErrorContains(err, tt.error) + }) + } +} + +func TestParser(t *testing.T) { + suite.Run(t, new(parserTestSuite)) +}