wcfsoap.net-corecryptographyws-security

Sign Soap 1.1 body with .Net Core 3.1


I want to connect from .Net Core 3.1 with C# to a web service that requires I sign the Soap 1.1 body according to WS-Security WS-Policy 2004/09.

This is a textual description of the policy requirement:

AsymmetricBindingAssertion indicates to use asymmetric encryption, where the requestor’s certificate (X509v3) must be used for the signature. The InitiatorToken field indicates that the request token must be an X509v3 token and that it must be included with all request messages, while the RecipientToken field indicates that response token has to be X509v3 but will not be included in any message. To identify the token, a keyIdentifier will be used – specified by MustSupportKeyRefIdentitier field. Timestamp is also needed for inclusion to circumvent replay attacks and as such - by default - this is also signed. The OnlySignEntireHeadersAndBody field dictates that only the entire header or body is allowed to sign – to mitigate XML Signature wrapping. And lastly, we only dictate that the Bodyelement of the SOAP Envelope needs to be signed.

I have added a connected service with Microsoft WCF Web Reference Provider in Visual Studio 2019 and all the entities are added in Reference.cs. I can connect to a mocked version of the service in SoapUI just fine without the WS-Policy requirement. I have validated the certificates and stuff, I just can't figure out how to sign the soap body.

I can't use WSHttpBinding because it produces Soap 1.2 and the service I'm trying to consume only understands Soap 1.1.

I've tried different approaches with CustomBinding, but seemingly it always burns down to using AsymmetricSecurityBindingElement which is not present in .Net Core.

We have an implementation in JavaScript that produces what I want:

<?xml version="1.0" encoding="utf-8"?>
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/" 
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
   xmlns:tns="xx" 
   xmlns:cmn="xxx">
   <soap:Header>
      <wsse:Security xmlns:wsse="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd" 
         xmlns:wsu="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" soap:mustUnderstand="1">
         <wsse:BinarySecurityToken EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3" wsu:Id="x509-uidxxx">MIIE...base64=</wsse:BinarySecurityToken>
         <Timestamp xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" Id="_1">
            <Created>2019-09-21T12:33:36Z</Created>
            <Expires>2019-09-21T12:43:36Z</Expires>
         </Timestamp>
         <Signature xmlns="http://www.w3.org/2000/09/xmldsig#">
            <SignedInfo>
               <CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
               <SignatureMethod Algorithm="http://www.w3.org/2000/09/xmldsig#rsa-sha1" />
               <Reference URI="#_0">
                  <Transforms>
                     <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
                     <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
                  </Transforms>
                  <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
                  <DigestValue>sc...base64=</DigestValue>
               </Reference>
               <Reference URI="#_1">
                  <Transforms>
                     <Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature" />
                     <Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#" />
                  </Transforms>
                  <DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1" />
                  <DigestValue>5J...base64=</DigestValue>
               </Reference>
            </SignedInfo>
            <SignatureValue>pa...base64=</SignatureValue>
            <KeyInfo>
               <wsse:SecurityTokenReference>
                  <wsse:Reference URI="#x509-uidxxx" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
               </wsse:SecurityTokenReference>
            </KeyInfo>
         </Signature>
      </wsse:Security>
   </soap:Header>
   <soap:Body Id="_0">
      // Lots of stuff
   </soap:Body>
</soap:Envelope>

Does anyone know if it is possible to sign the soap body using asymmetric encryption with C# in .Net Core 3.1 and produce Soap 1.1?


Solution

  • This is a late response but I had a similar requirement, to call a soap endpoint which required one-way TLS and ws-security, using .net core 3.1.

    First, adding the security header was pretty straightforward. Below is a MessageHeader implementation that adds the Security header with a timestamp. An instance of the class (WsSecurityHeader) is used in the message inspector shown below. You could also bake this header into the message inspector itself and not use WsSecurityHeader in the message inspector, since the message inspector rewrites the entire soap message anyway.

    using System;
    using System.ServiceModel.Channels;
    using System.Xml;
    
    namespace MyClient.WsSecurity
    {
        /// <summary>
        /// Adds a WS-Security header to the message, with a Timestamp. The header does not include the message signature,
        /// as the framework provides no mechanism to access the message body inside of a MessageHeader implementation.
        /// </summary>
        public sealed class WsSecurityHeader : MessageHeader
        {
            public override bool MustUnderstand => true;
          
            public override string Name => "Security";
            
            public const string SoapEnvelopeNamespace = "http://schemas.xmlsoap.org/soap/envelope/";
            public const string WsseUtilityNamespaceUrl = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd";
            public const string WsseNamespace = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd";
    
            public override string Namespace => WsseNamespace;
    
            protected override void OnWriteStartHeader(XmlDictionaryWriter writer, MessageVersion messageVersion)
            {
                writer.WriteStartElement("wsse", Name, Namespace);
                writer.WriteAttributeString("s", "mustUnderstand", SoapEnvelopeNamespace, "1");
    
                writer.WriteXmlnsAttribute("wsse", Namespace);
                writer.WriteXmlnsAttribute("wsu", WsseUtilityNamespaceUrl);
            }
    
            protected override void OnWriteHeaderContents(XmlDictionaryWriter writer, MessageVersion messageVersion)
            {
                // Timestamp
                writer.WriteStartElement("wsu", "Timestamp", WsseUtilityNamespaceUrl);
    
                writer.WriteAttributeString("wsu", "Id", WsseUtilityNamespaceUrl, "ws-security-timestamp");
    
                writer.WriteStartElement("wsu", "Created", WsseUtilityNamespaceUrl);
                writer.WriteValue(DateTimeOffset.Now.ToString("o"));
                writer.WriteEndElement();
    
                writer.WriteStartElement("wsu", "Expires", WsseUtilityNamespaceUrl);
                writer.WriteValue(DateTimeOffset.Now.AddMinutes(120).ToString("o"));
                writer.WriteEndElement();
    
                writer.WriteEndElement(); // Timestamp
            }
        }
    }
    

    In order to sign the Body element of the message, you'll need to implement a message inspector. The message inspector gives us access to the entire message, including the body and the header. We need to modify both. The message inspector below adds our Security header (WsSecurityHeader class, shown previously). We modify the Body element of the message to add an Id attribute that is used in the security header, to identify what element we are signing. We then create a signature xml element by signing the Body element and add the signature xml element to the header. The entire soap message is then reconstructed from our XmlDocument.

    using System.Security.Cryptography.X509Certificates;
    using System.ServiceModel;
    using System.ServiceModel.Channels;
    using System.ServiceModel.Dispatcher;
    using System.Xml;
    using System.Security.Cryptography.Xml;
    using System.IO;
    
    namespace MyClient.WsSecurity
    {
        /// <summary>
        /// Adds a ws-security x509 xml body signature to the outgoing message header.  It's annoying that Microsoft contributed to this 
        /// standard but it's not supported in .NET core.
        /// </summary>
        public sealed class WsSecurityMessageInspector : IClientMessageInspector
        {
            public const string BodyIdentifier = "ws-security-body-id"; // This can be whatever xml Id attribute value value we want
    
            public X509Certificate2 X509Certificate { get; }
         
            public WsSecurityMessageInspector() { }
    
            public WsSecurityMessageInspector(X509Certificate2 cert)
            {
                X509Certificate = cert;
            }
    
            public void AfterReceiveReply(ref Message reply, object correlationState) { }
    
            public object BeforeSendRequest(ref Message request, IClientChannel channel)
            {
                // Add the ws-Security header
                request.Headers.Add(new WsSecurityHeader());
            
                // Get the entire message as an xml doc, so we can sign the body.
                var xml = GetMessageAsString(request);
    
                XmlDocument doc = new XmlDocument();
                doc.PreserveWhitespace = false;
                doc.LoadXml(xml);
                
                XmlNamespaceManager nsmgr = new XmlNamespaceManager(doc.NameTable);
                nsmgr.AddNamespace("soapenv", WsSecurityHeader.SoapEnvelopeNamespace);
                nsmgr.AddNamespace("wsse", WsSecurityHeader.WsseNamespace);
    
                // The Body is the element we want to sign.
                var body = doc.SelectSingleNode("//soapenv:Body", nsmgr) as XmlElement;
    
                // Add the Id attribute to the Body, for the Reference element URI..
                var id = doc.CreateAttribute("wsu", "Id", WsSecurityHeader.WsseUtilityNamespaceUrl);
                id.Value = BodyIdentifier;
                body.Attributes.Append(id);
    
                // Here we do not adopt the SecurityTokenReference recommendation in the KeyInfo
                // section because it is not defined in the XML Signature standard. In lieu of the SecurityTokenReference, we
                // add KeyInfoX509Data directly to the KeyInfo node, in accordance with the XML Signature rfc (rfc3075).  The SignedXml
                // class does not seem to support the SecurityTokenReference, and it's not required.
                var signedXml = new SignedXmlWithUriFix(doc);
                signedXml.SignedInfo.SignatureMethod = SignedXml.XmlDsigRSASHA1Url;
    
                // This cannonicalization method is "recommended" in the ws-security standard, but seems to be required, at least
                // by Data Power. 
                signedXml.SignedInfo.CanonicalizationMethod = SignedXml.XmlDsigExcC14NTransformUrl;
    
                // Add the X509 certificate info to the KeyInfo section
                var keyInfo = new KeyInfo();
                var keyInfoData = new KeyInfoX509Data();
                
                keyInfoData.AddIssuerSerial(X509Certificate.IssuerName.Name, X509Certificate.SerialNumber);
                keyInfo.AddClause(keyInfoData);
    
                signedXml.SigningKey = X509Certificate.PrivateKey;
                signedXml.KeyInfo = keyInfo;
    
                // Add the reference to the SignedXml object.
                Reference reference = new Reference($"#{BodyIdentifier}");
                reference.DigestMethod = SignedXml.XmlDsigSHA1Url;
    
                signedXml.AddReference(reference);
    
                // Compute the signature.
                signedXml.ComputeSignature();
                
                // Get the Signature element
                XmlElement xmlDigitalSignature = signedXml.GetXml();
    
                // Append the Signature element to the XML document's Security header.
                XmlNode header = doc.SelectSingleNode("//soapenv:Envelope/soapenv:Header/wsse:Security", nsmgr);
                header.AppendChild(doc.ImportNode(xmlDigitalSignature, true));
    
                // Generate a new message from our XmlDocument.  We have to be careful here so that the XML is serialized 
                // with the same whitespace handling (via XmlWriter) as the signed xml (via XmlDocument). A bit sketchy.
                var newMessage = CreateMessageFromXmlDocument(request, doc);
    
                request = newMessage;
    
                return null;
            }
    
            private Message CreateMessageFromXmlDocument(Message message, XmlDocument doc)
            {
                MemoryStream ms = new MemoryStream();
                using (XmlWriter xmlWriter = XmlWriter.Create(ms, new XmlWriterSettings { OmitXmlDeclaration = true, Indent = false }))
                {
                    doc.WriteTo(xmlWriter);
                    xmlWriter.Flush();
                    xmlWriter.Close();
                    ms.Position = 0;
                }
                XmlDictionaryReader xdr = XmlDictionaryReader.CreateTextReader(ms, new XmlDictionaryReaderQuotas());
    
                var newMessage = Message.CreateMessage(xdr, int.MaxValue, message.Version);
    
                newMessage.Properties.CopyProperties(message.Properties);
    
                return newMessage;
            }
    
            private string GetMessageAsString(Message msg)
            {
                using (var sw = new StringWriter())
                using (var xw = new XmlTextWriter(sw))
                {
                    msg.WriteMessage(xw);
                    return sw.ToString();
                }
            }
    
            /// <summary>
            /// The SignedXml class chokes on a URI prefixed with "#", so we override the GetIdElement here.  The #
            /// is allowed by the XML Signature rfc (rfc3075), so this is really a bug fix for SignedXml.
            /// </summary>
            public class SignedXmlWithUriFix : SignedXml
            {
                public SignedXmlWithUriFix(XmlDocument xml) : base(xml)
                {
                }
                
                public SignedXmlWithUriFix(XmlElement xmlElement)
                    : base(xmlElement)
                {
                }
    
                public override XmlElement GetIdElement(XmlDocument doc, string id)
                {
                    XmlNamespaceManager nsManager = new XmlNamespaceManager(doc.NameTable);
                    nsManager.AddNamespace("wsu", WsSecurityHeader.WsseUtilityNamespaceUrl);
    
                    return doc.SelectSingleNode($"//*[@wsu:Id=\"{id}\"]", nsManager) as XmlElement;
                }
            }
        }
    }
    

    Next, create a behavior and add the message inspector.

    using System.Security.Cryptography.X509Certificates;
    using System.ServiceModel.Channels;
    using System.ServiceModel.Description;
    using System.ServiceModel.Dispatcher;
    
    namespace MyClient.WsSecurity
    {
        public sealed class WsSecurityHeaderBehavior : IEndpointBehavior
        {
            public X509Certificate2 X509Certificate { get; }
       
            public WsSecurityHeaderBehavior() { }
    
            public WsSecurityHeaderBehavior(X509Certificate2 cert)
            {
                X509Certificate = cert;
            }
    
            public void AddBindingParameters(ServiceEndpoint endpoint, BindingParameterCollection bindingParameters) { }
    
            public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
            {
                var inspector = new WsSecurityMessageInspector(X509Certificate);
                clientRuntime.ClientMessageInspectors.Add(inspector);
            }
    
            public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher) { }
    
            public void Validate(ServiceEndpoint endpoint) { }
        }
    }
    

    Finally, add the behavior to your soap client (helpful hint: re-use the same binding instance and endpointAddress to allow the channel factory to be cached by .net core -- at least that's the way I remember it working). Don't forget to wrap your client in a using block, or otherwise dispose of it after using it.

    var binding = new BasicHttpsBinding();
    binding.Security.Mode = BasicHttpsSecurityMode.Transport;
    
    var client= new YourWcfClient(binding, endpointAddress);
    
    // Configure ws-security signing
    client.ChannelFactory.Endpoint.EndpointBehaviors.Add(new WsSecurityHeaderBehavior(cert));
    

    This code has been successfully used to call a DataPower endpoint that requires one-way TLS and ws-security, with timestamp. There may be a better approach, but I could not find any working implementations for .net core. I may have missed a few things here, as I'm not very familiar with the details of SOAP nor Ws-Security (I only familiarized myself enough to hack this together). Good luck!