Search code examples
c#opensslcryptographyecdsaasn.1

C# fails to verify ECDSA (384-bit) base 64 digital signature from OpenSSL console line commands


[Moderators, I've had problems squeezing this question into the character limit, please be merciful.]

The use case is using OpenSSL on a Linux server to sign a license (plain text) file with a 384 bit Elliptic Curve Digital Server Algorithm (ECDSA), the verification of the digital signature takes place on a customer's Windows desktop OS running full (Windows) .NET Framework.

The license file and a Base 64 encoded digital signature are emailed to the customer (who is not on a shared corporate network). The customer is running a C# written .NET Framework (Windows edition) application and verification of the the licence and digital signature unlocks paid-for features.

Now, I say Linux but the example server side code given below is not yet in a Linux scripting language. I'm prototyping with VBA running on Windows 8, eventually I will convert over to a Linux scripting language but bear with me for the time being.

The point is I am using OpenSSL console commands and not compiling against any OpenSSL software development kit (C++ headers etc.).

One tricky part (and perhaps is the best place to begin code review) is the digging out of the X and Y co-ordinates that form the public key from the DER file. A DER key file is a binary encoded file that uses Abstract Syntax Notation (ASN1), there are free GUI programs out there such as Code Project ASN1. Editor that allow easy inspection, here is a screenshot of a public key file

enter image description here

Luckily, OpenSSL has its own inbuilt ASN1 parser so the same details are written to the console as the following

C:\OpenSSL-Win64\bin\openssl.exe asn1parse -inform DER -in n:\ECDSA\2017-11-03T193106\ec_pubkey.der
    0:d=0  hl=2 l= 118 cons: SEQUENCE
    2:d=1  hl=2 l=  16 cons: SEQUENCE
    4:d=2  hl=2 l=   7 prim: OBJECT            :id-ecPublicKey
   13:d=2  hl=2 l=   5 prim: OBJECT            :secp384r1
   20:d=1  hl=2 l=  98 prim: BIT STRING

So at offset 20 there are 98 bytes which contain the X and Y co-ordinates, at byte 20 is a tag (0x03) indicating a string is following and at byte 21 is the length, 98 (any length below 127 needs only one byte). So actually the real 98 bytes of data begin at byte 22 , so I read 100 bytes in total (98+2). At byte 22 is 0x00 which is how BIT STRINGS begin (see Point 5). At byte 23 is 0x04 which indicates that both X and Y follow which is known as uncompressed form (it is possible to give the X value and compute the Y in which case use 0x02 or 0x03). After the 0x04 comes the X and Y coordinates, 48 bytes each because 8 bits in a byte and 8*48=384.

So one digs out two (X & Y) very long hexadecimal numbers as strings. The next pain comes in creating the Xml file suitable for the C# code. The key class is C#'s ECDsaCng and the method to import is FromXmlString and it expected the file to implement standard Rfc4050. The Xml file that C#'s ECDsaCng imports requires X and Y to be in decimal and not hexadecimal so we have to write another function to convert, I translated from another language taken from another Stack Overflow question.

Here is the VBA code (there's quite a lot) and you'll need to change where it will write its working files. The two code blocks to run are EntryPoint1_RunECDSAKeyGenerationBatch_RunOnce and EntryPoint2_RunHashAndSignBatch

It should be taken as read that OpenSSL has been installed, my version is at C:\OpenSSL-Win64\

The full VBA code is here because SO has a 30000 charact limit. The likely culprit code is given

Option Explicit
Option Private Module

'******* Requires Tools->References to the following libraries
'* Microsoft ActiveX Data Objects 6.1 Library           C:\Program Files (x86)\Common Files\System\ado\msado15.dll
'* Microsoft Scripting Runtime                          C:\Windows\SysWOW64\scrrun.dll
'* Microsoft XML, v.6.0                                 C:\Windows\SysWOW64\msxml6.dll
'* Windows Script HostObject Model                      C:\Windows\SysWOW64\wshom.ocx
'* Microsoft VBScript Regular Expressions 5.5           C:\Windows\SysWOW64\vbscript.dll\3

Private fso As New Scripting.FileSystemObject
Private Const sOPENSSL_BIN As String = "C:\OpenSSL-Win64\bin\openssl.exe"  '* installation for OpenSSL
Private msBatchDir As Variant '* hold over so we can sign multiple times

Private Function ExportECDSAToXml(ByVal sPublicKeyFile As String, ByVal sXmlFile As String) As Boolean

    '* C#'s ECDsaCng class has a FromXmlString method which imports public key from a xml file Rfc4050
    '* In this subroutine we use OpenSSL's asn1parse command to determine where the X and Y coordinates
    '* are to be found, we dig them out and then markup an Xml file

    '* sample output

    '<ECDSAKeyValue xmlns="http://www.w3.org/2001/04/xmldsig-more#">
    '  <DomainParameters>
    '    <NamedCurve URN="urn:oid:1.3.132.0.34" />
    '  </DomainParameters>
    '  <PublicKey>
    '    <X Value="28988690734503506507042353413239022820576378869683128926072865549806544603682841538004244894267242326732083660928511" xsi:type="PrimeFieldElemType" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" />
    '    <Y Value="26760429725303641669535466935138151998536365153900531836644163359528872675820305636066450549811202036369304684551859" xsi:type="PrimeFieldElemType" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" />
    '  </PublicKey>
    '</ECDSAKeyValue>


    Dim sAS1ParseCmd As String
    sAS1ParseCmd = sOPENSSL_BIN & " asn1parse -inform DER -in " & sPublicKeyFile

    Dim eAS1ParseStatus As WshExecStatus, sAS1ParseStdOut As String, sAS1ParseStdErr As String
    eAS1ParseStatus = RunShellAndWait(sAS1ParseCmd, sAS1ParseStdOut, sAS1ParseStdErr)
    Debug.Print sAS1ParseStdOut

    '* sample output from standard out pipe is given blow.
    '* we need to dig into the BIT STRING which is the final item
    '* we need offset and length which is always 20 and 98 for 384 bit ECDSA
    '* but I have written logic in case we want to upgrade to 512 or change of curve etc.
    '    0:d=0  hl=2 l= 118 cons: SEQUENCE
    '    2:d=1  hl=2 l=  16 cons: SEQUENCE
    '    4:d=2  hl=2 l=   7 prim: OBJECT            :id-ecPublicKey
    '   13:d=2  hl=2 l=   5 prim: OBJECT            :secp384r1
    '   20:d=1  hl=2 l=  98 prim: BIT STRING



    Dim vOutputSplit As Variant
    vOutputSplit = VBA.Split(sAS1ParseStdOut, vbNewLine)

    '* remove the traling blank line
    If Trim(vOutputSplit(UBound(vOutputSplit))) = "" Then ReDim Preserve vOutputSplit(0 To UBound(vOutputSplit) - 1)

    '* final line should be the long bit string, i.e. contain 'BIT STRING'
    Debug.Assert StrComp("BIT STRING", Right$(Trim(vOutputSplit(UBound(vOutputSplit))), 10)) = 0

    '* use regular expression to dig out offset and length
    Dim lOffset As Long, lLength As Long
    RegExpOffsetAndLengthFromASN1Parse Trim(vOutputSplit(UBound(vOutputSplit))), lOffset, lLength

    Dim abytes() As Byte
    Dim asHexs() As String  '* for debugging

    '* read in the whole file into a byte array
    ReadFileBytesAsBytes sPublicKeyFile, abytes

    '* for debugging create an array of hexadecimals
    ByteArrayToHexStringArray abytes, asHexs


    Dim bitString() As Byte
    '* need extra 2 bytes because of leading type and length bytes
    CopyArraySlice abytes, lOffset, lLength + 2, bitString()

    '* some asserts which pin down structure of the bytes
    Debug.Assert bitString(0) = 3  '* TAG for BIT STRING
    Debug.Assert bitString(1) = lLength

    '* From Point 5 at http://certificate.fyicenter.com/2221_View_Website_Server_Certificate_in_Google_Chrome.html
    '* "ASN.1 BIT STRING value is stored with DER encoding as the value itself with an extra leading byte of 0x00. "
    Debug.Assert bitString(2) = 0

    '* 0x04 means by x and y values follow, i.e. uncompressed
    '* (instead of just one from which the other can be derived, leading with 0x02 or 0x03)
    '* https://en.bitcoin.it/wiki/Elliptic_Curve_Digital_Signature_Algorithm
    Debug.Assert bitString(3) = 4
    'Stop



    Dim x() As Byte
    Dim y() As Byte

    '* slice out the 48 bits for nopth x and y
    '* why 48?  because 48*8=384 bits(change for 512)
    CopyArraySlice bitString, 4, 48, x()
    CopyArraySlice bitString, 52, 48, y()

    '* convert bytes to hex string for x coord
    Dim sHexX As String
    sHexX = ByteArrayToHexString(x(), "")

    Debug.Print "sHexX:" & sHexX

    '* convert bytes to hex string for y coord
    Dim sHexY As String
    sHexY = ByteArrayToHexString(y(), "")

    Debug.Print "sHexY:" & sHexY

    '* convert hexadeciumal to plain decimal
    '* as Xml file requires it
    Dim sDecX As String
    sDecX = HexToDecimal(sHexX)

    Debug.Print "sDecX:" & sDecX

    '* convert hexadeciumal to plain decimal
    '* as Xml file requires it
    Dim sDecY As String
    sDecY = HexToDecimal(sHexY)

    Debug.Print "sDecY:" & sDecY


    '* create the xml file from a template
    Dim dom2 As MSXML2.DOMDocument60
    Set dom2 = New MSXML2.DOMDocument60
    dom2.LoadXML ECDSAXml(sDecX, sDecY)
    Debug.Assert dom2.parseError.ErrorCode = 0


    dom2.Save sXmlFile

    Debug.Print dom2.XML
    Set dom2 = Nothing


    Debug.Assert CreateObject("Scripting.FileSystemObject").FileExists(sXmlFile)


End Function

Here is the output to the VBA immediate window which illustrates the console commands and the responses for running EntryPoint1_RunECDSAKeyGenerationBatch_RunOnce.


Creating batch directory :n:\ECDSA\2017-11-03T193106
C:\OpenSSL-Win64\bin\openssl.exe ecparam -genkey -name secp384r1 -out n:\ECDSA\2017-11-03T193106\ec_key.pem

C:\OpenSSL-Win64\bin\openssl.exe ec -pubout -outform DER -in n:\ECDSA\2017-11-03T193106\ec_key.pem -out n:\ECDSA\2017-11-03T193106\ec_pubkey.der
C:\OpenSSL-Win64\bin\openssl.exe ec -pubout -outform PEM -in n:\ECDSA\2017-11-03T193106\ec_key.pem -out n:\ECDSA\2017-11-03T193106\ec_pubkey.pem

C:\OpenSSL-Win64\bin\openssl.exe ec -noout -text -in n:\ECDSA\2017-11-03T193106\ec_key.pem -out n:\ECDSA\2017-11-03T193106\ec_key.txt

Private-Key: (384 bit)
priv:
    00:98:78:0d:c7:29:10:1c:9f:4d:75:b2:95:01:01:
    a9:d2:36:72:0d:77:6a:5c:57:8d:51:a0:53:27:05:
    9b:22:1c:c9:0a:1e:e1:27:06:92:c1:6c:2a:c4:bb:
    46:91:98:f6
pub: 
    04:bd:4a:38:04:69:d5:ba:fa:11:27:0f:a8:ef:70:
    3f:11:8d:e0:0f:e7:fd:26:ac:4d:40:32:7a:b5:9c:
    97:71:c1:80:72:1b:42:25:f8:a4:49:4d:8f:89:bf:
    1b:e9:6c:8c:f3:0b:02:db:89:b3:f7:92:e8:c4:a6:
    ce:04:88:10:51:cc:17:0b:b8:9c:9a:a6:3d:fd:ec:
    d4:99:c3:31:6b:22:1d:b6:41:fa:3c:0e:51:fe:86:
    67:bb:7e:86:ce:06:6c
ASN1 OID: secp384r1
NIST CURVE: P-384

C:\OpenSSL-Win64\bin\openssl.exe asn1parse -inform DER -in n:\ECDSA\2017-11-03T193106\ec_pubkey.der
    0:d=0  hl=2 l= 118 cons: SEQUENCE          
    2:d=1  hl=2 l=  16 cons: SEQUENCE          
    4:d=2  hl=2 l=   7 prim: OBJECT            :id-ecPublicKey
   13:d=2  hl=2 l=   5 prim: OBJECT            :secp384r1
   20:d=1  hl=2 l=  98 prim: BIT STRING        

sHexX:BD4A380469D5BAFA11270FA8EF703F118DE00FE7FD26AC4D40327AB59C9771C180721B4225F8A4494D8F89BF1BE96C8C
sHexY:F30B02DB89B3F792E8C4A6CE04881051CC170BB89C9AA63DFDECD499C3316B221DB641FA3C0E51FE8667BB7E86CE066C
sDecX:29134384736743232303148959866907873847020585008044539704341734517362687803911673703523083044584737202030832217844876
sDecY:37407743276271579329804703064876533532537408218368858949720169306023437854945515421210341789026319167790678153234028
<ECDSAKeyValue xmlns="http://www.w3.org/2001/04/xmldsig-more#">
          <DomainParameters>
            <NamedCurve URN="urn:oid:1.3.132.0.34" />
          </DomainParameters>
          <PublicKey>
            <X Value="28988690734503506507042353413239022820576378869683128926072865549806544603682841538004244894267242326732083660928511" xsi:type="PrimeFieldElemType" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" />
            <Y Value="26760429725303641669535466935138151998536365153900531836644163359528872675820305636066450549811202036369304684551859" xsi:type="PrimeFieldElemType" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" />
          </PublicKey>
        </ECDSAKeyValue>

Here is the VBA immediate window output for running EntryPoint2_RunHashAndSignBatch ...



    C:\OpenSSL-Win64\bin\openssl.exe dgst -sha256 -out n:\ECDSA\2017-11-03T193106\license.sha256 n:\ECDSA\2017-11-03T193106\license.txt

    SHA256(n:\ECDSA\2017-11-03T193106\license.txt)= 185f8db32271fe25f561a6fc938b2e264306ec304eda518007d1764826381969

    C:\OpenSSL-Win64\bin\openssl.exe dgst -sha256 -sign n:\ECDSA\2017-11-03T193106\ec_key.pem -out n:\ECDSA\2017-11-03T193106\license.sig n:\ECDSA\2017-11-03T193106\license.txt

    C:\OpenSSL-Win64\bin\openssl.exe base64 -in n:\ECDSA\2017-11-03T193106\license.sig -out n:\ECDSA\2017-11-03T193106\license.sigb64

    C:\OpenSSL-Win64\bin\openssl.exe dgst -sha256 -verify n:\ECDSA\2017-11-03T193106\ec_pubkey.pem -signature n:\ECDSA\2017-11-03T193106\license.sig n:\ECDSA\2017-11-03T193106\license.txt
    Verification success

Next we create a C# classic console application and paste in the following code to verify the digital signature remembering that the customer will be in receipt of a base64 version of the digital signature.

using System;
using System.Diagnostics;
using System.IO;
using System.Security.Cryptography;
using System.Xml;

namespace ECDSAVerSig
{
    class Program
    {
        static Action<string> feedback { get; set; }
        static byte[] fileContents = null;
        static byte[] signatureContents = null;

        static ECDsaCng client = null;
        static HashAlgorithm hashAlgo = new SHA256Managed();

        static String parentDirectory = null;

        static void Main(string[] args)
        {

            //* the following will be different for you!!!
            //* and will need to match what was output by the VBA script
            parentDirectory = "n:\\ECDSA\\2017-11-03T193106\\";
            Debug.Assert(Directory.Exists(parentDirectory));

            feedback = Console.WriteLine; // Abstract away 

            if (LoadSignature())
            {
                VerifySignature();
            }


        }



        static private Boolean VerifySignature()
        {
            try
            {
                // a byte array to store hash value
                byte[] hashedData = null;

                Debug.Assert(fileContents[0] == 'H');
                Debug.Assert(fileContents[1] == 'e');
                Debug.Assert(fileContents[2] == 'l');
                Debug.Assert(fileContents[3] == 'l');
                Debug.Assert(fileContents[4] == 'o');
                hashedData = hashAlgo.ComputeHash(fileContents);
                //'* hard coded check of "Hello" hash 
                Debug.Assert(hashedData[0] == 0x18);
                Debug.Assert(hashedData[1] == 0x5f);

                //* the following is consistently wrong though it is my best guess
                Boolean verified = client.VerifyHash(hashedData, signatureContents); //<-- Help required here StackOverflowers

                feedback("Verification:" + verified);

                if (verified)
                {
                    feedback("Hooray you got this 384 bit ECDSA code working! You absolute star!");
                } else
                {
                    feedback("Oh dear, still does not work.  Please keep twiddling.");

                }
                Debug.Assert(verified);

                return true;

            }
            catch (XmlException ex)
            {
                feedback("Problem with verification (Xml parse error):" + ex.ToString());
                return false;
            }
            catch (Exception ex)
            {
                feedback("Problem with verification :" + ex.ToString());
                return false;
            }
        }

        static private Boolean LoadSignature()
        {

            client = new ECDsaCng();
            try
            {

                System.Xml.XmlDocument dom = new System.Xml.XmlDocument();

                dom.Load(Path.Combine(parentDirectory,"ec_pubkey.xml"));

                string xml = dom.OuterXml;
                feedback(xml);
                client.FromXmlString(xml, ECKeyXmlFormat.Rfc4050);

                fileContents = System.IO.File.ReadAllBytes(Path.Combine(parentDirectory, "license.txt"));

                string base64SignatureContents = System.IO.File.ReadAllText(Path.Combine(parentDirectory, "license.sigB64"));
                signatureContents = Convert.FromBase64String(base64SignatureContents); 


                byte[] hashedData = hashAlgo.ComputeHash(fileContents);
                //'* hard coded check of "Hello" hash
                Debug.Assert(hashedData[0] == 0x18);
                Debug.Assert(hashedData[1] == 0x5f);


                return true;
            }
            catch (XmlException ex)
            {
                feedback("Problem with reading digital signature (Xml parse error):" + ex.ToString());
                return false;
            }

            catch (Exception ex)
            {
                feedback("Problem with reading digital signature:" + ex.ToString());
                return false;
            }
        }
    }
}

I have triple checked this code. I've made the license file a very short "Hello" and checked bytes and encoding. I have checked the hashes as well. I do not know what next to do. Please assist. Thanks in advance


Solution

  • Assuming you did everything else correctly - problem is different formats of signatures produced by openssl and .NET. Signature produced (and expected) by openssl is (surprise!) again ASN.1 encoded. Run

    openssl.exe asn1parse -in license.sig -inform DER
    

    and you will see

    0:d=0  hl=2 l= 101 cons: SEQUENCE
    2:d=1  hl=2 l=  49 prim: INTEGER           :F25556BBB... big number here
    53:d=1  hl=2 l=  48 prim: INTEGER          :3E98E7B376624FF.... big number
    

    So it's again sequence with two numbers, byte at (0-based) index 1 is total length, byte at index 3 is length of first number, then goes first number, after that byte with length of second number, then second number. Note that there might be optional padding involved (0 byte) which should be removed, so don't implement that as I vaguely described but read how to parse ASN.1 correctly.

    Anyway, .NET expects these two numbers concatenated together, without any ASN.1 stuff, so you again need to extract them. As a quick test - grab these two numbers you see from the above command output (they are in hex), concatenate together and convert from hex string to byte array, then use in your code as signatureContents. Alternatively, use this sample code (never use it for real extraction of those numbers) to extract numbers from your existing signature (if with this code you still get invalid signature - try approach above with copying data directly from asn1parse output):

    // only for testing purposes
    private static byte[] FromOpenSslSignature(byte[] data) {
        var rLength = data[3];
        byte[] rData = new byte[48];
        Array.Copy(data, 4 + (rLength - 48), rData, 0, 48);
        var sLength = data[5 + rLength];
        byte[] sData = new byte[48];
        Array.Copy(data, 6 + rLength + (sLength - 48), sData, 0, 48);
        return rData.Concat(sData).ToArray();
    }
    

    If you do everything correctly - signature will verify just fine.