From 4ad8dff4dcde132e81783dc334abd672322b370a Mon Sep 17 00:00:00 2001 From: Werner van Deventer Date: Fri, 18 Oct 2019 12:44:21 +0200 Subject: [PATCH] Added support for ExpressPay and EcobankPay EMV QR codes and fixed some validation issues. - Added test for 5 live EMV QR codes that failed to be parsed but are actually valid. - Fixed merchant information tag ID range validation, it can be from 2 to 51 and it's even extracted in that range. - Fixed validation failures when there is no additional data or merchant language template (both are optional). - Fixed Issue #2 by checking there is available length to substring before attempting it. - Fixed some code analysis warnings. - Assume if the merchant information has no child nodes (not a child TLV) then the Globally Unique Identifier IS the string, this sis the case for Visa and MasterCard PAN/Alias/Card Numbers present in actual EMV QR codes. Issue #2 --- src/StandardizedQR/MerchantPayload.cs | 2 +- .../Services/Decoding/MerchantDecoder.cs | 75 ++++++++--- .../Services/Encoding/IPayloadEncoding.cs | 2 +- .../Services/Encoding/MerchantEncoder.cs | 11 +- .../Validation/ValidateObjectAttribute.cs | 14 +- .../MerchantPayloadUnitTests.cs | 121 +++++++++++++++++- 6 files changed, 187 insertions(+), 38 deletions(-) diff --git a/src/StandardizedQR/MerchantPayload.cs b/src/StandardizedQR/MerchantPayload.cs index 09bfd09..0f6eb06 100644 --- a/src/StandardizedQR/MerchantPayload.cs +++ b/src/StandardizedQR/MerchantPayload.cs @@ -245,7 +245,7 @@ public IEnumerable Validate(ValidationContext validationContex if (null != MerchantAccountInformation && 1 <= MerchantAccountInformation.Count) { - var invalidIdentifiers = MerchantAccountInformation.Keys.Count(k => k < 26 || k > 51); + var invalidIdentifiers = MerchantAccountInformation.Keys.Count(k => k < 2 || k > 51); if (0 < invalidIdentifiers) { errors.Add(new ValidationResult(LibraryResources.MerchantAccountInformationInvalidIdentifier, new string[] { nameof(MerchantAccountInformation) })); diff --git a/src/StandardizedQR/Services/Decoding/MerchantDecoder.cs b/src/StandardizedQR/Services/Decoding/MerchantDecoder.cs index c9b9cd8..f519278 100644 --- a/src/StandardizedQR/Services/Decoding/MerchantDecoder.cs +++ b/src/StandardizedQR/Services/Decoding/MerchantDecoder.cs @@ -45,6 +45,28 @@ public MerchantPayload BuildPayload(ICollection tlvs) DecodeAccountInformation(tlvs, merchantPayload); DecodeUnreservedTemplate(tlvs, merchantPayload); + // Before validation we can remove additional data and the merchant language template if no data for them was available. + // They are optional fields but if they are not populated then validation fails. + if (null == merchantPayload.AdditionalData.AdditionalConsumerDataRequest + && null == merchantPayload.AdditionalData.BillNumber + && null == merchantPayload.AdditionalData.CustomerLabel + && null == merchantPayload.AdditionalData.LoyaltyNumber + && null == merchantPayload.AdditionalData.MobileNumber + && null == merchantPayload.AdditionalData.PurposeOfTransaction + && null == merchantPayload.AdditionalData.ReferenceLabel + && null == merchantPayload.AdditionalData.StoreLabel + && null == merchantPayload.AdditionalData.TerminalLabel) + { + merchantPayload.AdditionalData = null; + } + + if (null == merchantPayload.MerchantInformation.LanguagePreference + && null == merchantPayload.MerchantInformation.MerchantCityAlternateLanguage + && null == merchantPayload.MerchantInformation.MerchantNameAlternateLanguage) + { + merchantPayload.MerchantInformation = null; + } + return merchantPayload; } @@ -96,6 +118,11 @@ private void ParseTLVs(string data, ICollection tlvs) } index += 2; + if (data.Length - 4 < length) + { + break; + } + var value = data.Substring(index, length); index += length - 1; @@ -140,22 +167,32 @@ private void DecodeAccountInformation(ICollection tlvs, MerchantPayload mer foreach (var tlv in merchantAccountInfoTlvs) { var accountInfo = new MerchantAccountInformation(); - var globalUniqueIdentifierTlv = tlv.ChildNodes.FirstOrDefault(t => t.Tag == 0); - if (null != globalUniqueIdentifierTlv) + + // Visa and MasterCard simply have card numbers in their reserved space (02 and 04 for example - Issue #2). + // If there are no child nodes, then the data for this tag is not a TLV string, we could probably assume it's the GlobalUniqueIdentifier since it's a required field. + if (!tlv.ChildNodes.Any()) { - accountInfo.GlobalUniqueIdentifier = globalUniqueIdentifierTlv.Value; + accountInfo.GlobalUniqueIdentifier = tlv.Value; } - - var paymentNetworkSpecificTlvs = tlv.ChildNodes.Where(e => e.Tag >= 1 && e.Tag <= 99); - if (paymentNetworkSpecificTlvs.Any()) + else { - accountInfo.PaymentNetworkSpecific = new Dictionary(); - foreach (var item in paymentNetworkSpecificTlvs) + var globalUniqueIdentifierTlv = tlv.ChildNodes.FirstOrDefault(t => t.Tag == 0); + if (null != globalUniqueIdentifierTlv) { - accountInfo.PaymentNetworkSpecific.Add(item.Tag, item.Value); + accountInfo.GlobalUniqueIdentifier = globalUniqueIdentifierTlv.Value; + + var paymentNetworkSpecificTlvs = tlv.ChildNodes.Where(e => e.Tag >= 1 && e.Tag <= 99); + if (paymentNetworkSpecificTlvs.Any()) + { + accountInfo.PaymentNetworkSpecific = new Dictionary(); + foreach (var item in paymentNetworkSpecificTlvs) + { + accountInfo.PaymentNetworkSpecific.Add(item.Tag, item.Value); + } + } } } - + merchantPayload.MerchantAccountInformation.Add(tlv.Tag, accountInfo); } } @@ -174,19 +211,19 @@ private void DecodeUnreservedTemplate(ICollection tlvs, MerchantPayload mer if (null != globalUniqueIdentifierTlv) { unreservedTemplate.GlobalUniqueIdentifier = globalUniqueIdentifierTlv.Value; - } - var contextSpecificTlvs = tlv.ChildNodes.Where(e => e.Tag >= 1 && e.Tag <= 99); - if (contextSpecificTlvs.Any()) - { - unreservedTemplate.ContextSpecificData = new Dictionary(); - foreach (var item in contextSpecificTlvs) + var contextSpecificTlvs = tlv.ChildNodes.Where(e => e.Tag >= 1 && e.Tag <= 99); + if (contextSpecificTlvs.Any()) { - unreservedTemplate.ContextSpecificData.Add(item.Tag, item.Value); + unreservedTemplate.ContextSpecificData = new Dictionary(); + foreach (var item in contextSpecificTlvs) + { + unreservedTemplate.ContextSpecificData.Add(item.Tag, item.Value); + } } - } - merchantPayload.UnreservedTemplate.Add(tlv.Tag, unreservedTemplate); + merchantPayload.UnreservedTemplate.Add(tlv.Tag, unreservedTemplate); + } } } } diff --git a/src/StandardizedQR/Services/Encoding/IPayloadEncoding.cs b/src/StandardizedQR/Services/Encoding/IPayloadEncoding.cs index e8684c5..7eb751e 100644 --- a/src/StandardizedQR/Services/Encoding/IPayloadEncoding.cs +++ b/src/StandardizedQR/Services/Encoding/IPayloadEncoding.cs @@ -2,6 +2,6 @@ { public interface IPayloadEncoding { - string GeneratePayload(T instance); + string GeneratePayload(T payload); } } diff --git a/src/StandardizedQR/Services/Encoding/MerchantEncoder.cs b/src/StandardizedQR/Services/Encoding/MerchantEncoder.cs index ed721a3..28b207e 100644 --- a/src/StandardizedQR/Services/Encoding/MerchantEncoder.cs +++ b/src/StandardizedQR/Services/Encoding/MerchantEncoder.cs @@ -94,7 +94,7 @@ public string GeneratePayload(MerchantPayload payload) * ID, Length and Value, to be included in the QR Code, in their respective order, as well as the ID and Length of * the CRC itself (but excluding its Value). */ - sb.Append("6304"); // {id:63}{length:04} + sb.Append("6304"); //// {id:63}{length:04} var crc16ccittFalseParameters = CrcStdParams.StandartParameters[CrcAlgorithms.Crc16CcittFalse]; var crc = new Crc(crc16ccittFalseParameters).ComputeHash(System.Text.Encoding.UTF8.GetBytes(sb.ToString())); sb.Append(crc.ToHex(true).GetLast(4)); @@ -115,15 +115,16 @@ private string EncodeProperty(PropertyInfo property, T propertyValue) var emvSpecAttribute = (EmvSpecificationAttribute)property .GetCustomAttributes(typeof(EmvSpecificationAttribute), false) .First(); - - string id = emvSpecAttribute.Id.ToString("D2"); + string value = EncodePropertyValue(propertyValue); - string length = value.Length.ToString("D2"); - + if (string.IsNullOrWhiteSpace(value)) { return string.Empty; } + + string id = emvSpecAttribute.Id.ToString("D2"); + string length = value.Length.ToString("D2"); return $"{id}{length}{value}"; } diff --git a/src/StandardizedQR/Validation/ValidateObjectAttribute.cs b/src/StandardizedQR/Validation/ValidateObjectAttribute.cs index be71ca1..def5982 100644 --- a/src/StandardizedQR/Validation/ValidateObjectAttribute.cs +++ b/src/StandardizedQR/Validation/ValidateObjectAttribute.cs @@ -1,14 +1,16 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using System.Globalization; namespace StandardizedQR.Validation { - /// - /// Helper attribute that allows for recursive valdation using data annotations. - /// - /// - public class ValidateObjectAttribute : ValidationAttribute + [AttributeUsage(AttributeTargets.All, AllowMultiple = false)] + /// + /// Helper attribute that allows for recursive validation using data annotations. + /// + /// + public class ValidateObjectAttribute : ValidationAttribute { /// /// Returns true if ... is valid. diff --git a/test/StandardizedQR.XUnitTests/MerchantPayloadUnitTests.cs b/test/StandardizedQR.XUnitTests/MerchantPayloadUnitTests.cs index 0da2bf6..0423d52 100644 --- a/test/StandardizedQR.XUnitTests/MerchantPayloadUnitTests.cs +++ b/test/StandardizedQR.XUnitTests/MerchantPayloadUnitTests.cs @@ -94,6 +94,115 @@ public void DecodeQR() Assert.Equal("12345678", payload.UnreservedTemplate[91].ContextSpecificData[7]); } + [Fact] + public void DecodeQR1() + { + var qrData = "0002010102110213404587173785204155326311010619815204829953039365802GH5909CIB GHANA6005ACCRA622407088656730603088656730663041437"; + var payload = MerchantPayload.FromQR(qrData); + + Assert.Equal(1, payload.PayloadFormatIndicator); + Assert.Equal(11, payload.PointOfInitializationMethod); + Assert.Equal("1437", payload.CRC); + Assert.Equal(8299, payload.MerchantCategoryCode); + Assert.Equal(936, payload.TransactionCurrency); + Assert.Equal("CIB GHANA", payload.MerchantName); + Assert.Equal("ACCRA", payload.MerchantCity); + Assert.Equal("GH", payload.CountyCode); + + Assert.Equal("4045871737852", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier); + Assert.Equal("532631101061981", payload.MerchantAccountInformation[4].GlobalUniqueIdentifier); + + Assert.Equal("86567306", payload.AdditionalData.StoreLabel); + Assert.Equal("86567306", payload.AdditionalData.TerminalLabel); + } + + [Fact] + public void DecodeQR2() + { + var qrData = "0002010102110213404587194150404155326311017361105204581153039365802GH5913SUSAN ALLOTEY6005ACCRA622407080407330503080407330563049EE4"; + var payload = MerchantPayload.FromQR(qrData); + + Assert.Equal(1, payload.PayloadFormatIndicator); + Assert.Equal(11, payload.PointOfInitializationMethod); + Assert.Equal("9EE4", payload.CRC); + Assert.Equal(5811, payload.MerchantCategoryCode); + Assert.Equal(936, payload.TransactionCurrency); + Assert.Equal("SUSAN ALLOTEY", payload.MerchantName); + Assert.Equal("ACCRA", payload.MerchantCity); + Assert.Equal("GH", payload.CountyCode); + + Assert.Equal("4045871941504", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier); + Assert.Equal("532631101736110", payload.MerchantAccountInformation[4].GlobalUniqueIdentifier); + + Assert.Equal("04073305", payload.AdditionalData.StoreLabel); + Assert.Equal("04073305", payload.AdditionalData.TerminalLabel); + } + + [Fact] + public void DecodeQR3() + { + var qrData = "0002010102110213404587568745904155326311155509945204625353039365802GH5915MAXMART LIMITED6005ACCRA62240708620037450308620037456304C913"; + var payload = MerchantPayload.FromQR(qrData); + + Assert.Equal(1, payload.PayloadFormatIndicator); + Assert.Equal(11, payload.PointOfInitializationMethod); + Assert.Equal("C913", payload.CRC); + Assert.Equal(6253, payload.MerchantCategoryCode); + Assert.Equal(936, payload.TransactionCurrency); + Assert.Equal("MAXMART LIMITED", payload.MerchantName); + Assert.Equal("ACCRA", payload.MerchantCity); + Assert.Equal("GH", payload.CountyCode); + + Assert.Equal("4045875687459", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier); + Assert.Equal("532631115550994", payload.MerchantAccountInformation[4].GlobalUniqueIdentifier); + + Assert.Equal("62003745", payload.AdditionalData.StoreLabel); + Assert.Equal("62003745", payload.AdditionalData.TerminalLabel); + } + + [Fact] + public void DecodeQR4() + { + var qrData = "0002010102110213404587793527804155326311019494035204529553039365802GH5915JULITET LIMITED6005ACCRA62240708324313220308324313226304A5FA"; + var payload = MerchantPayload.FromQR(qrData); + + Assert.Equal(1, payload.PayloadFormatIndicator); + Assert.Equal(11, payload.PointOfInitializationMethod); + Assert.Equal("A5FA", payload.CRC); + Assert.Equal(5295, payload.MerchantCategoryCode); + Assert.Equal(936, payload.TransactionCurrency); + Assert.Equal("JULITET LIMITED", payload.MerchantName); + Assert.Equal("ACCRA", payload.MerchantCity); + Assert.Equal("GH", payload.CountyCode); + + Assert.Equal("4045877935278", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier); + Assert.Equal("532631101949403", payload.MerchantAccountInformation[4].GlobalUniqueIdentifier); + + Assert.Equal("32431322", payload.AdditionalData.StoreLabel); + Assert.Equal("32431322", payload.AdditionalData.TerminalLabel); + } + + [Fact] + public void DecodeQR5() + { + var qrData = "00020101021102154382871085619335204541153039365802GH5907PANDORA6005Accra63049C22"; + var payload = MerchantPayload.FromQR(qrData); + + Assert.Equal(1, payload.PayloadFormatIndicator); + Assert.Equal(11, payload.PointOfInitializationMethod); + Assert.Equal("9C22", payload.CRC); + Assert.Equal(5411, payload.MerchantCategoryCode); + Assert.Equal(936, payload.TransactionCurrency); + Assert.Equal("PANDORA", payload.MerchantName); + Assert.Equal("Accra", payload.MerchantCity); + Assert.Equal("GH", payload.CountyCode); + + Assert.True(payload.MerchantAccountInformation.Count == 1); + Assert.Equal("438287108561933", payload.MerchantAccountInformation[2].GlobalUniqueIdentifier); + + Assert.Null(payload.AdditionalData); + } + [Fact] public void PayloadWithSpecificationSample() { @@ -213,7 +322,7 @@ public void InvalidMerchantAccountInformationIdentifiers() MerchantCity = "Mexico City", }; - var payload = merchantPayload.GeneratePayload(); + merchantPayload.GeneratePayload(); }); } @@ -245,7 +354,7 @@ public void InvalidMerchantAccountInformationPaymentSpecificItems() MerchantCity = "Mexico City", }; - var payload = merchantPayload.GeneratePayload(); + merchantPayload.GeneratePayload(); }); } @@ -269,7 +378,7 @@ public void InvalidPayloadFormatIndicator() MerchantCity = "Mexico City", }; - var payload = merchantPayload.GeneratePayload(); + merchantPayload.GeneratePayload(); }); } @@ -294,7 +403,7 @@ public void InvalidTipOrConvenienceIndicator() TipOrConvenienceIndicator = 5 }; - var payload = merchantPayload.GeneratePayload(); + merchantPayload.GeneratePayload(); }); } @@ -319,7 +428,7 @@ public void MissingFixedTip() TipOrConvenienceIndicator = 2 }; - var payload = merchantPayload.GeneratePayload(); + merchantPayload.GeneratePayload(); }); } @@ -344,7 +453,7 @@ public void MissingPercentageTip() TipOrConvenienceIndicator = 3 }; - var payload = merchantPayload.GeneratePayload(); + merchantPayload.GeneratePayload(); }); } }