SO上有很多这样的问题,但没有一个有明确的答案,所以在花了很多时间在这个问题上之后,我将回答这个8年前的问题,希望它能对某人有所帮助。
我必须将带有密码摘要和签名时间戳(仅签名时间戳)的 SOAP 消息发送到黑盒服务器,我认为它是 Axis2。我使用不同的安全配置和 SignedXml 类的派生变体四处游荡,并成功地让我的消息看起来有些正确,但从未能够产生有效的签名。根据微软的说法,WCF 不像非 WCF 服务器那样规范化,WCF 遗漏了一些命名空间并以不同的方式重命名命名空间前缀,因此我永远无法让我的签名匹配。
因此,经过大量试验和错误,这是我的 DIY 方法:
- 定义一个负责创建整个安全标头的自定义 MessageHeader。
- 定义一个自定义 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 所说的:
- 需要创建带有随机数的密码摘要。
- 需要包含 BinarySecurityToken 的 Base64 表示。
- Timestamp 需要通过 xml-exc-c14n 规范进行规范化(只是将该部分拉出并重新格式化),确保在标题中包含命名空间 wsse、ns1、soapenv 和 xsd。
- 该时间戳部分需要经过 SHA256 哈希处理并添加到 SignedInfo 部分的 DigestValue 字段中。
- 需要对带有新 DigestValue 的 SignedInfo 部分进行规范化,确保包括命名空间 ns1、soapenv 和 xsd。
- 签名信息需要经过 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 类。这几乎可以让您在插入标头和传输消息之前更改任何您想要的请求。
这里有一篇关于它的好文章,它使用此方案将用户名密码 Nonce 添加到消息中:https ://weblog.west-wind.com/posts/2012/nov/24/wcf-wssecurity-and-wse-nonce -验证
您必须创建一个自定义 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 header 部分创建了一个模板。
间距当然很重要,因此请确保 xml 保持未格式化,并且如果您正在加载 XmlDocument,确保将 PreserveWhitespace 设置为 true 总是一个好主意:
XmlDocument doc = new XmlDocument() { PreserveWhitespace = true;}
所以现在我将模板保存在资源中,当我需要对时间戳进行签名时,我将时间戳模板加载到字符串中,用正确的时间戳 ID、创建和过期字段替换标签,所以我有这样的东西(用正确的命名空间,当然没有换行符):
<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 和上面计算的时间戳摘要值),然后我得到了 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 + 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);
就我而言,有一个额外的问题是密码需要进行 SHA1 哈希处理。如果您在 SoapUI 中设置 WS-Security 用户名,这就是 SoapUI 所称的“PasswordDigest Ext”。请记住,如果您仍然遇到身份验证问题,我花了很多时间才意识到我需要先对密码进行哈希处理。
还有一件事我不知道该怎么做,这里是如何从您的 X509 证书中获取 Base64 二进制安全令牌值:
var bstValue = Convert.ToBase64String(myCertificate.Export(X509ContentType.Cert));
最后,我从资源中提取我的 Security 标头模板并替换我收集或计算的所有相关值:UsernameTokenId、Username、Password Digest、Nonce、UsernameToken Created time、Timestamp fields、BinarySecurityToken 和 BinarySecurityTokenID(确保在KeyInfo 部分)、时间戳摘要、ID,最后是我的签名。关于 ID 的注释,我认为这些值并不重要,只要它们在文档中是唯一的,只要确保它们是相同的 ID,如果它们在请求中的其他地方被引用,查找“#”符号。
已编译的 XML 安全标头字符串被加载到 XmlDocument 中(请记住保留空格)并传递给自定义 MessageHeader 以在 CustomHeader.OnWriteHeaderContents 中序列化(参见上面的 CustomHeader)。
唷。希望这将为某人节省大量工作,为拼写错误或无法解释的步骤道歉。如果有人想出来的话,我很想看到一个优雅的纯 WCF 实现。