使用WS-Security的WCF服务仅需要签名时间戳

问题描述 投票:9回答:4

我需要向第三方提供服务,该服务将使用签名的时间戳发送soap消息。

如何配置我的服务以支持此功能?

更新我已经设法接近我们所追求的Soap消息的格式,但WCF坚持同时签署用户名和时间戳令牌,有没有办法修改绑定只签署时间戳?


进一步更新以下是我们的要求:

  • 时间戳元素必须签名。
  • 用于签名的证书上的CN名称必须与UsernameToken元素中的用户名给出匹配。
  • 用于签名的证书必须在BinarySecurityToken元素中发送。
  • KeyInfo元素必须只包含一个SecurityTokenReference元素,该元素必须用于引用BinarySecurityToken。
  • 必须指定规范化算法。
  • 必须指定SignatureMethod,并且必须是SHA-1或SHA-2算法。
  • 应该使用分离的签名。

有什么建议?

当前配置

客户端绑定

<bindings>
  <wsHttpBinding>
    <binding name="WSBC">
      <security mode="TransportWithMessageCredential">
        <transport clientCredentialType="Certificate" proxyCredentialType="None"></transport>
        <message clientCredentialType="UserName" negotiateServiceCredential="false" establishSecurityContext="false" />
      </security>
    </binding>
  </wsHttpBinding>
</bindings>

客户端端点

<client>
  <endpoint address="https://localhost/WcfTestService/Service2.svc"
  behaviorConfiguration="CCB" binding="wsHttpBinding"
  bindingConfiguration="WSBC"
  contract="ServiceReference2.IService2"
  name="wsHttpBinding_IService2" />
</client>

客户行为

<behaviors>
  <endpointBehaviors>
    <behavior name="MBB">
      <clientCredentials>
        <clientCertificate  findValue="03 58 d3 bf 4b e7 67 2e 57 05 47 dc e6 3b 52 7f f8 66 d5 2a"
                            storeLocation="LocalMachine"
                            storeName="My"
                            x509FindType="FindByThumbprint" />
        <serviceCertificate>
          <defaultCertificate findValue="03 58 d3 bf 4b e7 67 2e 57 05 47 dc e6 3b 52 7f f8 66 d5 2a"
                              storeLocation="LocalMachine"
                              storeName="My"
                              x509FindType="FindByThumbprint"  />
        </serviceCertificate>
      </clientCredentials>
    </behavior>
  </endpointBehaviors>
</behaviors>

服务绑定

<bindings>
  <wsHttpBinding>
    <binding name="ICB">
      <security mode="TransportWithMessageCredential">
        <transport clientCredentialType="Certificate" proxyCredentialType="None"></transport>
        <message    clientCredentialType="UserName" 
                    negotiateServiceCredential="false"
                    establishSecurityContext="false" />
      </security>
    </binding>
  </wsHttpBinding>
</bindings>

服务端点

<service name="WcfTestService.Service2" behaviorConfiguration="SCB">
    <endpoint     address="" binding="wsHttpBinding" contract="WcfTestService.IService2"
    bindingConfiguration="ICB" name="MS" />
</service>

服务行为

<behaviors>
  <serviceBehaviors>
    <behavior name="SCB">
      <serviceCredentials>
        <serviceCertificate     findValue="4d a9 d8 f2 fb 4e 74 bd a7 36 d7 20 a8 51 e2 e6 ea 7d 30 08"
                                storeLocation="LocalMachine"
                                storeName="TrustedPeople"   
                                x509FindType="FindByThumbprint" />
        <userNameAuthentication 
            userNamePasswordValidationMode="Custom" 
            customUserNamePasswordValidatorType="WcfTestService.UsernameValidator, WcfTestService" />
        <clientCertificate>
          <authentication certificateValidationMode="None" revocationMode="NoCheck" />
        </clientCertificate>
      </serviceCredentials>
      <serviceMetadata httpGetEnabled="true" />
      <serviceDebug includeExceptionDetailInFaults="false" />
    </behavior>
  </serviceBehaviors>
</behaviors>
wcf ws-security
4个回答
3
投票

您可能需要考虑一个自定义安全绑定类,它以您希望的方式实现安全性,而不是WCF默认值。

这些MSDN链接解释了Custom Bindings和SecurityBindingElement抽象基类:

http://msdn.microsoft.com/en-us/library/ms730305.aspx

http://msdn.microsoft.com/en-us/library/system.servicemodel.channels.securitybindingelement.aspx


1
投票

WCF本身不允许签署时间戳,但不签署用户名。首先,我很确定这与您面临的问题无关 - 服务器应该能够处理这两种情况。如果您确实需要它,那么我建议不要在安全性中使用用户名(例如“anonymousForCertificate”的安全模式),然后实现自定义消息编码器以手动将用户名/密码标签推送到正确位置的标头中(取小心不要更改消息中任何已签名的部分,主要是时间戳。


1
投票

在SO上有很多这样的问题,但是没有一个问题有明确的答案,所以在花了很多时间在这之后我就把这个8岁问题的答案留给我,希望它能帮到别人。

我不得不发送带有密码摘要和签名时间戳(仅签署时间戳)的SOAP消息到黑盒服务器,我认为它是Axis2。我使用不同的安全配置和SignedXml类的派生变体,并成功地使我的消息看起来有点正确,但从未能够生成有效的签名。根据微软的说法,WCF不会像非WCF服务器那样规范化,WCF会遗漏一些命名空间,并以不同方式重命名命名空间前缀,因此我永远无法让我的签名匹配。

经过大量的反复试验后,这是我的DIY方式:

  1. 定义负责创建整个安全标头的自定义MessageHeader。
  2. 定义自定义MessageInspector以重命名命名空间,添加缺少的命名空间,并将自定义安全标头添加到请求标头

这是我需要生成的请求的示例:

<soapenv:Envelope xmlns:ns1="http://somewebsite.com/" xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/" xmlns:xsd="https://anotherwebsite.com/xsd">
<soapenv: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">
        <wsse:UsernameToken wsu:Id="UsernameToken-1">
            <wsse:Username>username</wsse:Username>
            <wsse:Password Type="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-username-token-profile-1.0#PasswordDigest">aABCDiUsrOy8ScJkdABCD/ZABCD=</wsse:Password>
            <wsse:Nonce EncodingType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-soap-message-security-1.0#Base64Binary">ABCDxZ8IABCDg/pTK6E0Q==</wsse:Nonce>
            <wsu:Created>2019-03-07T21:31:00.281Z</wsu:Created>
        </wsse:UsernameToken>
        <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-1">...</wsse:BinarySecurityToken>
        <wsu:Timestamp wsu:Id="TS-1">
            <wsu:Created>2019-03-07T21:31:00Z</wsu:Created>
            <wsu:Expires>2019-03-07T21:31:05Z</wsu:Expires>
        </wsu:Timestamp>
        <ds:Signature Id="SIG-1" xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
            <ds:SignedInfo>
                <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
                    <ec:InclusiveNamespaces PrefixList="ns1 soapenv xsd" xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"/>
                </ds:CanonicalizationMethod>
                <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
                <ds:Reference URI="#TS-1">
                    <ds:Transforms>
                        <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#">
                            <ec:InclusiveNamespaces PrefixList="wsse ns1 soapenv xsd" xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"/>
                        </ds:Transform>
                    </ds:Transforms>
                    <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
                    <ds:DigestValue>ABCDmhUOmjhBRPabcdB1wni53mabcdOzRMo3ABCDVbw=</ds:DigestValue>
                </ds:Reference>
            </ds:SignedInfo>
            <ds:SignatureValue>...</ds:SignatureValue>
            <ds:KeyInfo Id="KI-1">
                <wsse:SecurityTokenReference wsu:Id="STR-1">
                    <wsse:Reference URI="#X509-1" ValueType="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-x509-token-profile-1.0#X509v3"/>
                </wsse:SecurityTokenReference>
            </ds:KeyInfo>
        </ds:Signature>
    </wsse:Security>
</soapenv:Header>
<soapenv:Body>
    ...
</soapenv:Body>

所以这就是XML所说的:

  1. 需要创建带有随机数的密码摘要。
  2. 需要包含BinarySecurityToken的Base64表示。
  3. 时间戳需要通过xml-exc-c14n规范进行规范化(只是该部分被拉出并重新格式化),确保在标头中包含命名空间wsse,ns1,soapenv和xsd。
  4. 该时间戳部分需要SHA256散列并添加到SignedInfo部分中的DigestValue字段。
  5. 带有新DigestValue的SignedInfo部分需要进行规范化,确保包含命名空间ns1,soapenv和xsd。
  6. 签名信息需要SHA256散列,然后RSA加密,结果添加到SignatureValue字段。

自定义邮件标题

通过注入自定义消息头,我可以将任何我想要的xml写入请求的头部。这篇文章指出了我正确的方向https://stackoverflow.com/a/39090724/6077517

这是我使用的标题:

class CustomSecurityHeader : MessageHeader
{
    // This is data I'm passing into my header from the MessageInspector 
    // that will be used to create the security header contents
    public HeaderData HeaderData { get; set; }

    // Name of the header
    public override string Name
    {
        get { return "Security"; }
    }

    // Header namespace
    public override string Namespace
    {
        get { return "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"; }
    }

    // Additional namespace I needed
    public string wsuNamespace
    {
        get { return "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"; }
    }

    // This is where the start tag of the header gets written
    // add any required namespaces here
    protected override void OnWriteStartHeader(XmlDictionaryWriter writer, MessageVersion messageVersion)
    {
        writer.WriteStartElement("wsse", Name, Namespace);
        writer.WriteXmlnsAttribute("wsse", Namespace);
        writer.WriteXmlnsAttribute("wsu", wsuNamespace);
    }

    // This is where the header content will be written into the request
    protected override void OnWriteHeaderContents(XmlDictionaryWriter writer, MessageVersion messageVersion)
    {
        XmlDocument xmlDoc = MyCreateSecurityHeaderFunction(HeaderData); // My function that creates the security header contents.
        var securityElement = doc.FirstChild; // This is the "<security.." portion of the xml returned
        foreach(XmlNode node in securityElement.ChildNodes)
        {
            writer.WriteNode(node.CreateNavigator(), false);
        }
        return;
    }
}

消息检查器

要将标头放入请求中,我将覆盖MessageInspector类。这几乎可以让您在插入标题和传输消息之前更改有关所需请求的任何内容。

这里有一篇很好的文章,它使用这个方案在消息中添加用户名密码现时:https://weblog.west-wind.com/posts/2012/nov/24/wcf-wssecurity-and-wse-nonce-authentication

您必须创建一个自定义EndpointBehavior来注入检查器。

public class CustomInspectorBehavior : IEndpointBehavior
{
    // Data I'm passing to my EndpointBehavior that will be used to create the security header
    public HeaderData HeaderData
    {
        get { return this.messageInspector.HeaderData; }
        set { this.messageInspector.HeaderData = value; }
    }

    // My custom MessageInspector class
    private MessageInspector messageInspector = new MessageInspector();

    public void AddBindingParameters(ServiceEndpoint endpoint, System.ServiceModel.Channels.BindingParameterCollection bindingParameters)
    {
    }

    public void ApplyDispatchBehavior(ServiceEndpoint endpoint, EndpointDispatcher endpointDispatcher)
    {
    }

    public void Validate(ServiceEndpoint endpoint)
    {
    }

    public void ApplyClientBehavior(ServiceEndpoint endpoint, ClientRuntime clientRuntime)
    {
        // Add the custom message inspector here
        clientRuntime.MessageInspectors.Add(messageInspector);
    }
}

这是我的消息检查器的代码:

public class MessageInspector : IClientMessageInspector
{
    // Data to be used to create the security header
    public HeaderData HeaderData { get; set; }

    public void AfterReceiveReply(ref System.ServiceModel.Channels.Message reply, object correlationState)
    {
        var lastResponseXML = reply.ToString(); // Not necessary but useful for debugging if you want to see the response.
    }

    public object BeforeSendRequest(ref System.ServiceModel.Channels.Message request, System.ServiceModel.IClientChannel channel)
    {
        // This might not be necessary for your case but I remove a bunch of unnecessary WCF-created headers from the request.
        List<string> removeHeaders = new List<string>() { "Action", "VsDebuggerCausalityData", "ActivityId" };
        for (int h = request.Headers.Count() - 1; h >= 0; h--)
        {
            if (removeHeaders.Contains(request.Headers[h].Name))
            {
                request.Headers.RemoveAt(h);
            }
        }

        // Make changes to the request.
        // For this case I'm adding/renaming namespaces in the header.
        var container = XElement.Parse(request.ToString()); // Parse request into XElement
        // Change "s" namespace to "soapenv"
        container.Add(new XAttribute(XNamespace.Xmlns + "soapenv", "http://schemas.xmlsoap.org/soap/envelope/"));
        container.Attributes().Where(a => a.Name.LocalName == "s").Remove();
        // Add other missing namespace
        container.Add(new XAttribute(XNamespace.Xmlns + "ns1", "http://somewebsite.com/"));
        container.Add(new XAttribute(XNamespace.Xmlns + "xsd", "http://anotherwebsite.com/xsd"));
        requestXml = container.ToString();

        // Create a new message out of the updated request.
        var ms = new MemoryStream();
        var sr = new StreamWriter(ms);
        var writer = new StreamWriter(ms);
        writer.Write(requestXml);
        writer.Flush();
        ms.Position = 0;

        var reader = XmlReader.Create(ms);
        request = Message.CreateMessage(reader, int.MaxValue, request.Version);

        // Add my custom security header
        // This is responsible for writing the security headers to the message
        CustomSecurityHeader header = new CustomSecurityHeader();
        // Pass data required to build security header
        header.HeaderData = new HeaderData()
        {
            Certificate = this.HeaderData.Certificate,
            Username = this.HeaderData.Username,
            Password = this.HeaderData.Password
            // ... Whatever else might be needed
        };

        // Add custom header to request headers
        request.Headers.Add(header);

        return request;
    }
}

将消息检查器添加到客户端代理

我保持绑定非常简单,因为我自己添加了所有安全性内容,并且不希望添加任何意外的标头。

// IMPORTANT - my service required TLS 1.2, add this to make that happen
System.Net.ServicePointManager.SecurityProtocol = System.Net.SecurityProtocolType.Tls12;

// Encoding
var encoding = new TextMessageEncodingBindingElement();
encoding.MessageVersion = MessageVersion.Soap11;

// Transport
var transport = new HttpsTransportBindingElement();

CustomBinding binding = new CustomBinding();
binding.Elements.Add(encoding);
binding.Elements.Add(transport);

var myProxy = new MyProxyClass(binding, new EndpointAddress(endpoint));

// Add message inspector behavior to alter security header.
// data contains info to create the header such as username, password, certificate, etc.
MessageInspector = new CustomInspectorBehavior() { HeaderData = data }; 
myProxy.ChannelFactory.Endpoint.EndpointBehaviors.Add(MessageInspector);

创建安全标头XML

这有点难看,但我最终做的是创建安全头的规范化部分的XML模板,填写值,散列并适当地签署SignedInfo部分,然后将这些部分组合成完整的安全头。我宁愿在代码中构建它们,但XmlDocument不会维护我添加的属性的顺序,这会破坏我的规范化XML和我的签名,所以我保持简单。

为了确保我的部分正确地规范化,我使用了一个名为SC14N https://www.cryptosys.net/sc14n/index.html的工具。我输入了一个示例XML请求和对我想要的部分的引用以及任何包含的命名空间的规范化,并返回了相应的XML。我将返回的XML保存到模板中,用以后可以替换的标签替换值和ID。我为Timestamp部分创建了一个模板,为SignedInfo部分创建了一个模板,为整个Security头部分创建了一个模板。

间距当然很重要,因此请确保xml保持未格式化,如果您正在加载XmlDocument,那么确保将PreserveWhitespace设置为true始终是个好主意:

XmlDocument doc = new XmlDocument() { PreserveWhitespace = true;}

所以现在我将模板保存在资源中,当我需要签署我的Timestamp时,我将时间戳模板加载到字符串中,用正确的Timestamp ID,Created和Expires字段替换标签,所以我有这样的东西(带有正确的名称空间,当然没有换行符):

<wsu:Timestamp xmlns:ns1="..." xmlns:soapenv="..." xmlns:wsse=".." xmlns:wsu=".." wsu:Id="TI-3">
    <wsu:Created>2019-05-07T21:31:00Z</wsu:Created>
    <wsu:Expires>2019-05-07T21:36:00Z</wsu:Expires>
</wsu:Timestamp>

然后得到哈希:

// Get hash of timestamp.
SHA256Managed shHash = new SHA256Managed();
var fileBytes = System.Text.Encoding.UTF8.GetBytes(timestampXmlString);
var hashBytes = shHash.ComputeHash(fileBytes);
var digestValue = Convert.ToBase64String(hashBytes);

接下来我需要一个SignedInfo部分的模板。我从我的资源中取出它,并替换相应的标记(在我的例子中是时间戳引用ID和上面计算的timestamp digestValue),然后我得到该SignedInfo部分的哈希:

// Get hash of the signed info
SHA256Managed shHash = new SHA256Managed();
fileBytes = System.Text.Encoding.UTF8.GetBytes(signedInfoXmlString);
hashBytes = shHash.ComputeHash(fileBytes);
var signedInfoHashValue = Convert.ToBase64String(hashBytes);

然后我签署签名信息的哈希值以获得签名:

using (var rsa = MyX509Certificate.GetRSAPrivateKey())
{
    var signatureBytes = rsa.SignHash(hashBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
    SignatureValue = Convert.ToBase64String(signatureBytes); // This is my signature!
}

如果此操作失败,请确保您的证书设置正确,它还应该有一个私钥。如果您运行的是旧版本的框架,则可能需要跳过一些环节来获取RSA密钥。见https://stackoverflow.com/a/38380835/6077517

用户名密码摘要随机数

我没有签署用户名,但我必须计算密码摘要。它被定义为Base64(SHA1(Nonce + CreationTime +密码))。

    // Create nonce
    SHA1CryptoServiceProvider sha1Hasher = new SHA1CryptoServiceProvider();
    var nonce = Guid.NewGuid().ToString("N");
    var nonceHash = sha1Hasher.ComputeHash(Encoding.UTF8.GetBytes(nonce));
    var NonceValue = Convert.ToBase64String(nonceHash);

    var NonceCreatedTime = DateTimeOffset.UtcNow.ToString("yyyy-MM-ddThh:mm:ss.fffZ");

    // Create password digest Base64( SHA1(Nonce + Created + Password) )
    var nonceBytes = Convert.FromBase64String(NonceValue); // Important - convert from Base64
    var createdBytes = Encoding.UTF8.GetBytes(NonceCreatedTime);
    var passwordBytes = Encoding.UTF8.GetBytes(Password);

    var concatBytes = new byte[nonceBytes.Length + createdBytes.Length + passwordBytes.Length];
    System.Buffer.BlockCopy(nonceBytes, 0, concatBytes, 0, nonceBytes.Length);
    System.Buffer.BlockCopy(createdBytes, 0, concatBytes, nonceBytes.Length, createdBytes.Length);
    System.Buffer.BlockCopy(passwordBytes, 0, concatBytes, nonceBytes.Length + createdBytes.Length, passwordBytes.Length);

    // Hash the combined buffer
    var hashedConcatBytes = sha1Hasher.ComputeHash(concatBytes);
    var PasswordDigest = Convert.ToBase64String(hashedConcatBytes);

在我的情况下,有一个额外的问题,密码需要SHA1散列。如果您在SoapUI中设置WS-Security用户名,那么这就是SoapUI所谓的“PasswordDigest Ext”。如果您仍然遇到身份验证问题,请记住这一点,我花了很多时间才意识到我需要首先哈希密码。

还有一件事我不知道怎么做,这里是如何从X509证书中获取Base64二进制安全性令牌值:

var bstValue = Convert.ToBase64String(myCertificate.Export(X509ContentType.Cert));

最后,我从资源中提取安全标头模板,并替换我收集或计算的所有相关值:UsernameTokenId,Username,Password Digest,Nonce,UsernameToken Created time,Timestamp fields,BinarySecurityToken和BinarySecurityTokenID(确保此ID也在KeyInfo部分),Timestamp Digest,ID,最后是我的签名。关于ID的注释,我认为只要它们在文档中是唯一的,这些值就不重要,只要确保它们是相同的ID,如果它们被请求中的其他地方引用,则查找'#'标志。

XML的编译安全头字符串是加载到XmlDocument中的(记住保留空格)并传递给CustomHeader.OnWriteHeaderContents中的序列化的自定义MessageHeader(参见上面的CustomHeader)。

呼。希望这会为某些人节省大量工作,为拼写错误或无法解释的步骤道歉。我很乐意看到一个优雅的纯WCF实现所有这一切,如果有人想出一个。


0
投票

您可以使用消息合同执行此操作,请参阅:http://msdn.microsoft.com/en-us/library/ms730255.aspx

以下是上述链接中的示例:

[MessageContract]
public class PatientRecord 
{
   [MessageHeader(ProtectionLevel=None)] public int recordID;
   [MessageHeader(ProtectionLevel=Sign)] public string patientName;
   [MessageHeader(ProtectionLevel=EncryptAndSign)] public string SSN;
   [MessageBodyMember(ProtectionLevel=None)] public string comments;
   [MessageBodyMember(ProtectionLevel=Sign)] public string diagnosis;
   [MessageBodyMember(ProtectionLevel=EncryptAndSign)] public string medicalHistory;
}

请注意保护级别None,Sign,EncryptAndSign

© www.soinside.com 2019 - 2024. All rights reserved.