/* $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;
}
}
}