/* $Id: MakeEnvio.cs $ * Last updated: * $Date: 2021-02-05 00:51:00 $ * $Version: 1.2.0 $ */ /******************************* LICENSE *********************************** * Copyright (C) 2020-21 David Ireland, DI Management Services Pty Limited. * All rights reserved. <https://di-mgt.com.au> <https://cryptosys.net> * The code in this module is licensed under the terms of the MIT license. * For a copy, see <http://opensource.org/licenses/MIT> **************************************************************************** */ /* Changelog: * v1.2.0: * + Fixed problem with <DD> element and '/" entities * + Changed checks using .IndexOf from >0 to >=0. * v1.1.0: (skipped version number to align with VerifyDoc.cs). */ using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Text.RegularExpressions; using System.IO; using System.Diagnostics; /* * DI Management CryptoSys Libraries available from <https://cryptosys.net/>. * EITHER add references to * `diCrSysPKINet.dll` (installed by default in `C:\Program Files (x86)\CryptoSysPKI\DotNet`) * `diSc14nNet.dll` (installed by default in `C:\Program Files (x86)\Sc14n\DotNet`) * `diXmlsqNet.dll` (installed by default in `C:\Program Files (x86)\xmlsq\DotNet`) * OR add the C# source code files `CryptoSysPKI.cs`, `diSc14nNet.cs` and `diXmlsqNet.cs` * directly to your project. */ using Pki = CryptoSysPKI; using Sc14n; using Xmlsq; namespace DIManagement.MakeEnvio { class Program { static void Main(string[] args) { string envioDoc; // Setup to display debugging info in console in Debug mode // (Should be ignored in final Release mode) TextWriterTraceListener[] listeners = new TextWriterTraceListener[] { new TextWriterTraceListener(Console.Out) }; Debug.Listeners.AddRange(listeners); // Debug to check libraries are available and where Debug.WriteLine("PKI Version=" + Pki.General.Version() + " " + Pki.General.ModuleName() + " [" + Pki.General.CompileTime() + "]"); Debug.WriteLine("SC14N Version=" + Sc14n.Gen.Version() + " " + Sc14n.Gen.ModuleName() + " [" + Sc14n.Gen.CompileTime() + "]"); Debug.WriteLine("XMLSQ Version=" + Xmlsq.Gen.Version() + " " + Xmlsq.Gen.ModuleName() + " [" + Xmlsq.Gen.CompileTime() + "]"); Debug.Assert(Pki.General.Version() >= 120200, "PKI version must be 12.4.0 or above"); Debug.Assert(Sc14n.Gen.Version() >= 20100, "SC14N version must be 2.1.0 or above"); // Go do the business envioDoc = MakeEnvio.ProcessEnvioDoc("output-enviodte.xml", // Output XML file to create "user.cer", // User's signing certificate "user.pfx", // User's private key file "password", // Password for private key file "caf33.key", // Private key for CAF "template-enviodte.xml", // Template for outer document, completed as necessary // (params) variable-length list of base DTE documents to be signed... "dte-33-1.xml", "dte-33-2.xml"); // Returns either the name of the file just created or an error message beginning with "**ERROR" if (envioDoc.IndexOf("**ERROR") >= 0) { Console.WriteLine(envioDoc); } else { Console.WriteLine("Created file '{0}'", envioDoc); } // Go do the business - part 2 envioDoc = MakeEnvio.ProcessEnvioDoc("output-boleta.xml", // Output XML file to create "user.cer", // User's signing certificate "user.pfx", // User's private key file "password", // Password for private key file "caf39.key", // Private key for CAF "template-boleta.xml", // Template for outer document, completed as necessary // (params) variable-length list of base DTE documents to be signed... "dte-b1.xml", "dte-b2.xml"); // Returns either the name of the file just created or an error message beginning with "**ERROR" if (envioDoc.IndexOf("**ERROR") >= 0) { Console.WriteLine(envioDoc); } else { Console.WriteLine("Created file '{0}'", envioDoc); } } } class MakeEnvio { // GLOBAL DEFAULT ALGORITHMS // These match the algorithms hard-coded into the Signature template // (change one, remember to change the other) const Pki.HashAlgorithm hashAlg = Pki.HashAlgorithm.Sha1; const Pki.SigAlgorithm sigAlg = Pki.SigAlgorithm.Rsa_Sha1; const Sc14n.DigAlg digAlg = Sc14n.DigAlg.Sha1; const Sc14n.TranMethod tranMethod = Sc14n.TranMethod.Inclusive; // Compulsory placeholder expected in documents to be signed. const string SIGPLACE = "<Signature>@!SIGNATURE!@</Signature>"; /// <summary> /// Create signed Envio document. /// </summary> /// <param name="outputxmlFile">Name of output file to create.</param> /// <param name="certFile">Signer's X.509 certificate file (.cer).</param> /// <param name="keyFile">Signer's private key file (.pfx)</param> /// <param name="password">Password for private key file.</param> /// <param name="cafKeyFile">CAF private key file.</param> /// <param name="outerTemplate">XML template for outer Envio document.</param> /// <param name="dtedocs">List of DTE XML documents to be signed (comma-separated list of filenames)</param> /// <returns>Name of output file created if successful, or an error message that begins with "**ERROR:"</returns> public static string ProcessEnvioDoc(string outputxmlFile, string certFile, string keyFile, string password, string cafKeyFile, string outerTemplate, params string[] dtedocs) { string strMsg; StringBuilder sbCafKey; StringBuilder sbPriKey; string pubkey; string certStr = ""; List<string> dtelist = new List<string>(); string xmlsetdte; string sig; byte[] xmldata; string signingtime; // Compulsory placeholder expected in outer template. const string SETOFDTEPLACE = "<DTE>@!SET-OF-DTE!@</DTE>"; // Read in outer template xmlsetdte = File.ReadAllText(outerTemplate); // Check for required placeholders if (!xmlsetdte.Contains(SIGPLACE)) { strMsg = String.Format("**ERROR: Placeholder '{0}' is missing", SIGPLACE); return strMsg; } if (!xmlsetdte.Contains(SETOFDTEPLACE)) { strMsg = String.Format("**ERROR: Placeholder '{0}' is missing", SETOFDTEPLACE); return strMsg; } // If provided, read in certificate file to a one-line base64 string if (!String.IsNullOrEmpty(certFile)) { certStr = Pki.X509.ReadStringFromFile(certFile); if (certStr.Length == 0) { strMsg = String.Format("**ERROR: Failed to read X.509 certificate in '{0}'", certFile); return strMsg; } } // Read in CAF private key (no password) sbCafKey = Pki.Rsa.ReadPrivateKey(cafKeyFile, ""); if (sbCafKey.Length == 0) { strMsg = String.Format("**ERROR: Failed to read CAF private key in '{0}'", cafKeyFile); return strMsg; } Debug.WriteLine("CAF key has " + Pki.Rsa.KeyBits(sbCafKey.ToString()) + " bits"); // Read in private key from PFX file sbPriKey = Pki.Rsa.ReadPrivateKey(keyFile, password); if (sbPriKey.Length == 0) { strMsg = String.Format("**ERROR: Failed to read private key in '{0}'", keyFile); return strMsg; } Debug.WriteLine("Private key has " + Pki.Rsa.KeyBits(sbPriKey.ToString()) + " bits"); // Check private key matches certificate if (!String.IsNullOrEmpty(certFile)) { pubkey = Pki.Rsa.ReadPublicKey(certStr).ToString(); if (Pki.Rsa.KeyMatch(sbPriKey.ToString(), pubkey) != 0) { strMsg = String.Format("**ERROR: private key and certificate do not match."); return strMsg; } } // Set signing time in <xs:datetime> form signingtime = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"); Debug.WriteLine(signingtime); // Iterate through list of DTE documents for (int i = 0; i < dtedocs.Length; i++) { string s = ProcessDte(dtedocs[i], certStr, sbPriKey, sbCafKey, signingtime); // Catch error - result contains "**ERROR" if (s.IndexOf("**ERROR") >= 0) { return s; } // Store signed DTE document to be used later. dtelist.Add(s); } Debug.WriteLine("\nProcessing outer document..."); xmlsetdte = xmlsetdte.Replace("@!TIMESTAMP!@", signingtime); xmlsetdte = xmlsetdte.Replace("@!NUM-DTE!@", dtelist.Count.ToString()); // Insert DTE docs into Envio outer XML string toinsert = String.Join("\n", dtelist); xmlsetdte = xmlsetdte.Replace("<DTE>@!SET-OF-DTE!@</DTE>", toinsert); // Extract ID of SetDTE for Signature reference string docid = Xmlsq.Query.GetText(xmlsetdte, "//SetDTE/@ID"); Debug.WriteLine("SetDTE ID=" + docid); // Convert XML string to bytes: NB explicit Latin-1 encoding. xmldata = System.Text.Encoding.GetEncoding("iso-8859-1").GetBytes(xmlsetdte); // Compute the signature over the element SetDTE sig = MakeSignature(xmldata, docid, "SetDTE", sbPriKey, certStr); xmlsetdte = xmlsetdte.Replace("<Signature>@!SIGNATURE!@</Signature>", sig); // Final check for uncompleted '@!..!@' int nret = xmlsetdte.IndexOf("@!"); if (nret >= 0) { Debug.WriteLine("WARNING: uncompleted '@!..!@' items"); } // Write out the final output file File.WriteAllText(outputxmlFile, xmlsetdte, Encoding.GetEncoding("iso-8859-1")); // Clean up private keys Pki.Wipe.String(sbCafKey); Pki.Wipe.String(sbPriKey); return outputxmlFile; } /// <summary> /// Process an individual DTE document. /// </summary> /// <param name="xmlFile">Base DTE file to be processed with placeholders of form "@!...!@".</param> /// <param name="certStr">User's X.509 certificate as a base64 string.</param> /// <param name="sbPriKey">User's private signing key in Pki internal string form.</param> /// <param name="sbCafKey">User's CAF private key in Pki internal string form.</param> /// <param name="signingtime">Signing time as string in xs:dateTime form.</param> /// <returns>Signed DTE document as a string, or an error message that begins with "**ERROR:"</returns> static string ProcessDte(string xmlFile, string certStr, StringBuilder sbPriKey, StringBuilder sbCafKey, string signingtime) { string xmlStr, ddelem; byte[] b, xmlData; string strMsg; string dig; string frmtSig; string docid; int n; string sig; string filefullpath, filestem, filedir, newxmlfile; string fecha; // Compulsory placeholder expected in DTE base document. const string FRMTSIGPLACE = "@!FRMT-SIG!@"; // NOTE: we use standard Unicode strings for string manipulation and regex, // and byte arrays for crypto and C14N operations. Debug.WriteLine("\nProcessing file: " + xmlFile); // Read in the base XML file as bytes xmlData = File.ReadAllBytes(xmlFile); if (xmlData.Length == 0) { strMsg = String.Format("**ERROR: failed to read XML file '{0}'", xmlFile); return strMsg; } // Convert XML byte input to a Unicode string for regex/replace editing // Try to guess encoding: expecting either Latin-1 or UTF-8 (or just plain US-ASCII, a subset of UTF-8) n = Pki.Cnv.CheckUTF8(xmlData); // Returns 0 if invalid UTF-8 or >0 if valid UTF-8 Debug.WriteLine("CheckUTF8 returns " + n); if (0 == n) { // Not valid UTF-8, so probably Latin-1 xmlStr = System.Text.Encoding.GetEncoding("iso-8859-1").GetString(xmlData); ShowNonAscii(xmlStr); } else { xmlStr = System.Text.Encoding.UTF8.GetString(xmlData); ShowNonAscii(xmlStr); } //Console.WriteLine(HeadTail(s, 100)); // Check for required placeholders if (!xmlStr.Contains(SIGPLACE)) { strMsg = String.Format("**ERROR: Placeholder '{0}' is missing in file '{1}'", SIGPLACE, xmlFile); return strMsg; } if (!xmlStr.Contains(FRMTSIGPLACE)) { strMsg = String.Format("**ERROR: Placeholder '{0}' is missing in file '{1}'", FRMTSIGPLACE, xmlFile); return strMsg; } // Set signing time in document placeholder Debug.WriteLine(signingtime); xmlStr = xmlStr.Replace("@!TIMESTAMP!@", signingtime); // And set the Fecha Emision Contable del DTE (AAAA-MM-DD) fecha = signingtime.Substring(0, 10); // Truncate timestamp to <xs:date> form xmlStr = xmlStr.Replace("@!FECHA!@", fecha); // [2021-02-05] FIX // We can use Xmlsq to extract the flattened DD element - NO! // PROBLEM: Xpath converts XML entities ' and &qout; to literal characters, e.g. " and ' //ddelem = Xmlsq.Query.FullQuery(xmlStr, "//DD", Query.Opts.Raw | Query.Opts.Trim); //if (ddelem.Length == 0) { // strMsg = String.Format("**ERROR: Cannot find DD element in DTE document"); // return strMsg; //} // SOLUTION: Use regex instead to extract the <DD> element. Match match = Regex.Match(xmlStr, @"(<DD.*?</DD>)", RegexOptions.Singleline); if (!match.Success) { strMsg = String.Format("**ERROR: Cannot find DD element in DTE document"); return strMsg; } ddelem = match.Value; // Now flatten it - this is the input for the FRMT signature ddelem = Regex.Replace(ddelem, @">\s+<", @"><"); Console.WriteLine("\n--\n" + ddelem + "\n--\n"); // Convert to bytes in Latin-1 encoding b = System.Text.Encoding.GetEncoding("iso-8859-1").GetBytes(ddelem); // Compute SHA-1 digest in base64 dig = Pki.Cnv.ToBase64(Pki.Hash.BytesFromBytes(b, hashAlg)); Debug.WriteLine("SHA1(<DD>)=" + dig); // Compute FRMT signature over the flattened, Latin-1-encoded <DD> element frmtSig = Pki.Sig.SignDigest(Pki.Cnv.FromBase64(dig), sbCafKey.ToString(), "", sigAlg); Debug.WriteLine("SIG(<DD>)=" + frmtSig); // Insert this signature in placeholder @!FRMT-SIG!@ xmlStr = xmlStr.Replace("@!FRMT-SIG!@", frmtSig); // Copy updated XML string to array of bytes, this time in UTF-8 encoding xmlData = System.Text.Encoding.UTF8.GetBytes(xmlStr); // Do a test C14N calc on this data to see if any XML problems dig = Sc14n.C14n.ToDigest(xmlData, "", 0, 0, 0); //Debug.WriteLine("Test digest on composed XML=" + dig); if (dig.Length == 0) { strMsg = String.Format("**ERROR: XML problem: '{0}'", Sc14n.Err.LastError()); return strMsg; } // Extract ID of Documento for Signature reference docid = Xmlsq.Query.GetText(xmlStr, "//Documento/@ID"); Debug.WriteLine("Documento ID=" + docid); // Compute the signature over the element <Documento> sig = MakeSignature(xmlData, docid, "Documento", sbPriKey, certStr); //Debug.WriteLine("---\n" + sig + "\n---"); // Insert the completed Signature into the parent DTE document xmlStr = xmlStr.Replace("<Signature>@!SIGNATURE!@</Signature>", sig); // Final check for uncompleted '@!..!@' int nret = xmlStr.IndexOf("@!"); if (nret >= 0) { Debug.WriteLine("WARNING: uncompleted '@!..!@' items"); } // Debugging... ShowNonAscii(xmlStr); // Output XML should now be complete // Convert to UTF-8 bytes for XML check xmlData = System.Text.Encoding.UTF8.GetBytes(xmlStr); // Final check dig = Sc14n.C14n.ToDigest(xmlData, "", 0, 0, 0); if (dig.Length == 0) { strMsg = String.Format("**ERROR: XML internal problem: '{0}'", Sc14n.Err.LastError()); return strMsg; } // Save this DTE doc as a file for checking. // We'll add some extra XML stuff so we can check this individual file on the verifier site // https://www.aleksey.com/xmlsec/xmldsig-verifier.html // To verify: open the -signed.xml document in a text editor (we recommend Notepad++ which automatically detects encoding) // then copy-and-paste the entire document into the input field on the web site. // NOTE: you can verify these individual signed DTE documents, but it won't work for the outer Envio document (because namespaces). // Compose output filename filefullpath = Path.GetFullPath(xmlFile); filestem = Path.GetFileNameWithoutExtension(filefullpath); filedir = Path.GetDirectoryName(filefullpath); newxmlfile = Path.Combine(filedir, filestem + "-signed"); newxmlfile = Path.ChangeExtension(newxmlfile, ".xml"); //Debug.WriteLine("newxmlfile=" + newxmlfile); // Add header to XML document and save string header = @"<?xml version='1.0' encoding='ISO-8859-1'?> <!DOCTYPE DTE [ <!ATTLIST Documento ID ID #IMPLIED> ]>"; string temps = header + "\n" + xmlStr; File.WriteAllText(newxmlfile, temps, Encoding.GetEncoding("iso-8859-1")); Debug.WriteLine(String.Format("Saved temp DTE file '{0}'", newxmlfile)); // Return the XML doc as a string (without the extra DOCTYPE header) return xmlStr; } /// <summary> /// Create the Signature element to be inserted in <c><Signature>@!SIGNATURE!@</Signature></c>placeholder. /// </summary> /// <param name="xmldata">XML data in byte array including element-to-be-signed</param> /// <param name="docid">ID of element to be signed</param> /// <param name="elemName">Name of element to be signed</param> /// <param name="sbPriKey">RSA private signing key as internal string</param> /// <param name="certStr">User's signing certificate as a base64 string</param> /// <returns>String containing the completed Signature element</returns> /// <remarks>Example element-to-be-signed: <c>Documento ID="Ejemplo_F101T39"</c> /// => <c>Reference URI="#Ejemplo_F101T39"</c> /// </remarks> static string MakeSignature(byte[] xmldata, string docid, string elemName, StringBuilder sbPriKey, string certStr) { string sig, sigval, digval; byte[] b; string xs; int nsigs; string siref; // Get Signature template sig = SignatureTemplate(); // 1. Insert all required values in the SignedInfo element // 1.1 Insert docid in the Signature element @!DOCID!@ sig = sig.Replace("@!DOCID!@", docid); // Compute digest value for C14N of element-to-be-signed digval = Sc14n.C14n.ToDigest(xmldata, elemName, Tran.SubsetByTag, digAlg, tranMethod); Debug.WriteLine("DigestValue=" + digval); Debug.Assert(digval.Length > 0, "Failed to compute C14N digest value"); // 1.2 Insert DigestValue in the Signature element @!DIGVAL!@ sig = sig.Replace("@!DIGVAL!@", digval); // Compute digest of SignedInfo // Need to include entire document with all parent namespaces to propagate down to SignedInfo // Make a temp string of entire document so we can insert the partially-made Signature element into it string doc = System.Text.Encoding.UTF8.GetString(xmldata); doc = doc.Replace("<Signature>@!SIGNATURE!@</Signature>", sig); // Count number of Signature elements in doc nsigs = Xmlsq.Query.Count(doc, "//Signature"); siref = String.Format("SignedInfo[{0}]", nsigs); // Now back to a temp byte array to perform C14N on SignedInfo b = System.Text.Encoding.UTF8.GetBytes(doc); digval = Sc14n.C14n.ToDigest(b, siref, Sc14n.Tran.SubsetByTag, digAlg, tranMethod); Debug.WriteLine(String.Format("Digest({0})={1}", siref, digval)); Debug.Assert(digval.Length > 0, "Failed to find digest of SignedInfo"); //Debug.WriteLine(System.Text.Encoding.Default.GetString(Sc14n.C14n.ToBytes(b, "SignedInfo", Tran.SubsetByTag))); // Compute signature value and insert in Signature string sigval = Pki.Sig.SignDigest(Pki.Cnv.FromBase64(digval), sbPriKey.ToString(), "", sigAlg); Debug.WriteLine(String.Format("Signature={0}", sigval)); Debug.Assert(sigval.Length > 0, "Failed to compute signature"); sig = sig.Replace("@!SIGVAL!@", "\n" + Wrap(sigval) + "\n"); // Insert all remaining values into the Signature string // (NB these do not affect the SignatureValue) // Extract and substitute RSAKeyValue components xs = Pki.Rsa.KeyValue(sbPriKey.ToString(), "Modulus"); sig = sig.Replace("@!RSA-MOD!@", "\n" + Wrap(xs) + "\n"); xs = Pki.Rsa.KeyValue(sbPriKey.ToString(), "Exponent"); sig = sig.Replace("@!RSA-EXP!@", xs); // Insert the cert string after line wrapping xs = "\n" + Wrap(certStr) + "\n"; sig = sig.Replace("@!CERTIFICATE!@", xs); // Return the Signature string return sig; } /////////////////////////// // UTILITIES /////////////////////////// /// <summary> /// Flatten an XML document string. /// </summary> /// <param name="s">String containing XML document</param> /// <returns>Flattened document as a string.</returns> static string FlattenXML(string s) { s = Regex.Replace(s, @">\s+<", @"><"); return s; } // Show the first and last n characters of a string public static string HeadTail(string s, int n) { if (s.Length <= 2 * n) return s; return String.Format("{0}...{1}", s.Substring(0, n), s.Substring(s.Length - n, n)); } // Use for debugging - show any non-ASCII characters found in the string s. static void ShowNonAscii(string s) { bool foundone = false; foreach (char c in s) { if ((int)c >= 128) { if (!foundone) { Debug.WriteLine("Non-ASCII chars found..."); foundone = true; } Debug.Write(String.Format("{0:X} ", (int)c)); } } if (foundone) Debug.WriteLine(""); } // Wrap a long single-line string at 64 columns public static string Wrap(string singleLineString) { // Based on answer by Nikita B in Stack Exchange // https://codereview.stackexchange.com/questions/141501/wrapping-single-line-string-to-multiple-lines-with-specific-length const int columns = 64; // Better way to find ceil(length/columns)... int rows = (singleLineString.Length + columns - 1) / columns; if (rows < 2) return singleLineString; return String.Join( Environment.NewLine, Enumerable.Range(0, rows) .Select(i => i * columns) .Select(i => singleLineString .Substring(i, Math.Min(columns, singleLineString.Length - i))) ); } /// <summary> /// Template for Signature element. /// </summary> /// <returns>Signature template string.</returns> /// <remarks>Hard-coded algorithms here must match global default algorithms.</remarks> static string SignatureTemplate() { string s = @"<Signature xmlns=""http://www.w3.org/2000/09/xmldsig#""> <SignedInfo> <CanonicalizationMethod Algorithm=""http://www.w3.org/TR/2001/REC-xml-c14n-20010315""/> <SignatureMethod Algorithm=""http://www.w3.org/2000/09/xmldsig#rsa-sha1""/> <Reference URI=""#@!DOCID!@""> <Transforms> <Transform Algorithm=""http://www.w3.org/TR/2001/REC-xml-c14n-20010315""/> </Transforms> <DigestMethod Algorithm=""http://www.w3.org/2000/09/xmldsig#sha1""/> <DigestValue>@!DIGVAL!@</DigestValue> </Reference> </SignedInfo> <SignatureValue>@!SIGVAL!@</SignatureValue> <KeyInfo> <KeyValue> <RSAKeyValue> <Modulus>@!RSA-MOD!@</Modulus> <Exponent>@!RSA-EXP!@</Exponent> </RSAKeyValue> </KeyValue> <X509Data> <X509Certificate>@!CERTIFICATE!@</X509Certificate> </X509Data> </KeyInfo> </Signature>"; return s; } } }