WCF Service with WS-Security requires Signed Timestamp only
I need to provide a service to a third-party that will be sending soap messages with a signed Timestamp.
How can I configure my service to support this?
UPDATE I've managed to get close to the format of the Soap message that we're after but WCF insists on signing both the username and the timestamp tokens, Is there a way to modify the binding to only sign the timestamp?
Further Update Here are our requirements:
- The Timestamp element MUST be signed.
- The CN name on the certificate used for signing MUST match the Username give in the UsernameToken element.
- The certificate used for signing MUST be sent in the BinarySecurityToken element.
- The KeyInfo element MUST only contain a SecurityTokenReference element, which must be used to reference the BinarySecurityToken.
- A canonicalization algorithm MUST be specified.
- The SignatureMethod MUST be specified and MUST be the SHA-1 or SHA-2 alghorithm.
- Detached Signatures SHOULD be used.
Any suggestions?
CURRENT CONFIG
Client Binding
<bindings>
<wsHttpBinding>
<binding name="WSBC">
<security mode="TransportWithMessageCredential">
<transport clientCredentialType="Cert开发者_高级运维ificate" proxyCredentialType="None"></transport>
<message clientCredentialType="UserName" negotiateServiceCredential="false" establishSecurityContext="false" />
</security>
</binding>
</wsHttpBinding>
</bindings>
Client Endpoint
<client>
<endpoint address="https://localhost/WcfTestService/Service2.svc"
behaviorConfiguration="CCB" binding="wsHttpBinding"
bindingConfiguration="WSBC"
contract="ServiceReference2.IService2"
name="wsHttpBinding_IService2" />
</client>
Client Behavior
<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>
Service Binding
<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>
Serice Endpoint
<service name="WcfTestService.Service2" behaviorConfiguration="SCB">
<endpoint address="" binding="wsHttpBinding" contract="WcfTestService.IService2"
bindingConfiguration="ICB" name="MS" />
</service>
Service Behavior
<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>
There are a bunch of questions like this on SO but none of them have definitive answers, so after spending way to much time on this I'm leaving my answer to this 8 year old question in hopes it'll help somebody.
I had to send a SOAP message with a Password Digest and signed Timestamp (only sign the Timestamp) to a black box server, I think it was Axis2. I monkeyed around with different security configurations and derived variations of the SignedXml class and succeeded in getting my message to look somewhat correct but was never able to produce a valid signature. According to Microsoft, WCF doesn't canonicalize the same way non-WCF servers do and WCF leaves out some namespaces and renames namespace prefixes differently so I could never get my signatures to match up.
So after a ton of trial and error here's my DIY way of doing it:
- Define a custom MessageHeader that is responsible for creating the entire security header.
- Define a custom MessageInspector to rename namespaces, add missing namespaces, and add my custom security header to the request headers
Here's an example of the request I needed to produce:
<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>
So this is what the XML is saying:
- Password digest with nonce needs to be created.
- Base64 representation of the BinarySecurityToken needs to be included.
- The Timestamp needs to be canonicalized (just that section pulled out and reformatted) via xml-exc-c14n specifications making sure to include the namespaces wsse, ns1, soapenv, and xsd in the header.
- That timestamp section needs to be SHA256 hashed and added to the DigestValue field in the SignedInfo section.
- The SignedInfo section with the new DigestValue needs to be canonicalized making sure to include namespaces ns1, soapenv, and xsd.
- Signed info needs to be SHA256 hashed then RSA encrypted with the result added to the SignatureValue field.
Custom Message Header
By injecting a custom message header I can write out any xml I want into the header of the request. This post pointed me in the right direction https://stackoverflow.com/a/39090724/6077517
This is the header I used:
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;
}
}
Message Inspector
To get the header into the request I override the MessageInspector class. This pretty much lets you change anything about the request that you want before headers are inserted and message transmitted.
There's a good article about it here that uses this scheme to add a Username Password Nonce to a message: https://weblog.west-wind.com/posts/2012/nov/24/wcf-wssecurity-and-wse-nonce-authentication
You have to create a custom EndpointBehavior to inject the inspector.
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);
}
}
And here's the code for my message inspector:
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;
}
}
Add message inspector to client proxy
I kept my binding pretty simple since I'm adding all the security stuff myself and didn't want any unexpected headers added.
// 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);
Create Security Header XML
This is kind of ugly but what I ended up doing was to create XML templates of canonicalized sections of the security header, filling in the values, hashing and signing the SignedInfo section appropriately, then combining the pieces into a full security header. I would have preferred to build them up in code but XmlDocument wouldn't maintain the order of the attributes I was adding which was messing up my canonicalized XML and my signature, so I kept it simple.
To make sure my sections were canonicalized correctly, I used a tool called SC14N https://www.cryptosys.net/sc14n/index.html. I entered in a sample XML request and a reference to the section I wanted canonicalized along with any included namespaces and it returned the appropriate XML. I saved the XML it returned into a template replacing the values and ID's with tags I could replace later. I created a template for the Timestamp section, a template for the SignedInfo section, and a template for the entire Security header section.
Spacing is of course important, so make sure the xml remains unformatted and if you're loading an XmlDocument it's always a good idea to make sure to set PreserveWhitespace to true:
XmlDocument doc = new XmlDocument() { PreserveWhitespace = true;}
So now I have my templates saved in resources, when I need to sign my Timestamp, I load the timestamp template into a string, replace the tags with the proper Timestamp ID, Created, and Expires fields, so I have something like this (with proper namespaces and without line breaks of course):
<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>
Then get the hash:
// 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);
Next I need a template of my SignedInfo section. I pull that from my resources, and replace the appropriate tags (in my case the timestamp reference ID and the timestamp digestValue calculated above), then I get the hash of that SignedInfo section:
// 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);
Then I sign the hash of the signed info to get the signature:
using (var rsa = MyX509Certificate.GetRSAPrivateKey())
{
var signatureBytes = rsa.SignHash(hashBytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1);
SignatureValue = Convert.ToBase64String(signatureBytes); // This is my signature!
}
If this fails make sure your certificate is setup correctly, it should also have a private key. If you're running an older version of the framework you may have to jump through some hoops to get the RSA key. See https://stackoverflow.com/a/38380835/6077517
Username Password Digest Nonce
I didn't have to sign the username but I had to calculate the password digest. It's defined as Base64( SHA1(Nonce + CreationTime + Password) ).
// 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);
In my case there was an extra gotcha that the Password needed to be SHA1 hashed. That's what SoapUI calls "PasswordDigest Ext" if you're setting up a WS-Security Username in SoapUI. Keep that in mind if you're still having authentication problems, I spent way to much time before realizing that I needed to hash my password first.
One more thing I didn't know how to do, here's how to get the Base64 Binary Security Token Value from your X509 certificate:
var bstValue = Convert.ToBase64String(myCertificate.Export(X509ContentType.Cert));
Finally I pull my Security header template from resources and replace all the relevant values that I collected or calculated: UsernameTokenId, Username, Password Digest, Nonce, UsernameToken Created time, Timestamp fields, BinarySecurityToken and BinarySecurityTokenID (make sure this ID is also referenced in the KeyInfo section), Timestamp Digest, ID's and finally my Signature. A note on ID's, I don't think the values matter as long as they're unique in the document, just make sure they're the same ID's if they're being referenced elsewhere in the request, look for the '#' sign.
The compiled security header string of XML is what gets loaded into an XmlDocument (remember to preserve whitespace) and passed to the custom MessageHeader to be serialized out in the CustomHeader.OnWriteHeaderContents (see CustomHeader above).
Whew. Hopefully this will save somebody a lot of work, apologies for typos or unexplained steps. I would LOVE to see an elegant pure-WCF implementation of all this if anybody has figured one out.
You may want to consider a custom security binding class that implements the security just the way you want it, rather than the WCF default.
These MSDN links explain Custom Bindings and the SecurityBindingElement abstract base class:
http://msdn.microsoft.com/en-us/library/ms730305.aspx
http://msdn.microsoft.com/en-us/library/system.servicemodel.channels.securitybindingelement.aspx
WCF does not natively allow to sign the timestamp but not the username. First I'm pretty sure this is not related to the problem you are facing - a server should be able to handle both cases. If you do need it then I suggest to not use username at all in the security (e.g. security mode of "anonymousForCertificate") and then implement a custom message encoder to manually push the username/password tags into the header in the right place (take care not to change any signed part in the message, mainly the timestamp).
You can do this with message contracts, see: http://msdn.microsoft.com/en-us/library/ms730255.aspx
Here is an example from the above link:
[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;
}
Note the protection levels None, Sign, EncryptAndSign
精彩评论