//----------------------------------------------------------------------- // // Copyright (c) Andrew Arnott. All rights reserved. // //----------------------------------------------------------------------- namespace DotNetOpenAuth.OpenId { using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Diagnostics.Contracts; using System.Globalization; using System.IO; using System.Linq; using System.Net; using System.Security; using System.Security.Cryptography; using System.Security.Cryptography.X509Certificates; using System.Security.Permissions; using System.Text; using System.Text.RegularExpressions; using System.Xml; using System.Xml.XPath; using DotNetOpenAuth.Messaging; using DotNetOpenAuth.OpenId.RelyingParty; using DotNetOpenAuth.Xrds; using DotNetOpenAuth.Yadis; /// /// The discovery service to support host-meta based discovery, such as Google Apps for Domains. /// /// /// The spec for this discovery mechanism can be found at: /// http://groups.google.com/group/google-federated-login-api/web/openid-discovery-for-hosted-domains /// and the XMLDSig spec referenced in that spec can be found at: /// http://wiki.oasis-open.org/xri/XrdOne/XmlDsigProfile /// public class HostMetaDiscoveryService : IIdentifierDiscoveryService { /// /// The URI template for discovery host-meta on domains hosted by /// Google Apps for Domains. /// private static readonly HostMetaProxy GoogleHostedHostMeta = new HostMetaProxy("https://www.google.com/accounts/o8/.well-known/host-meta?hd={0}", "hosted-id.google.com"); /// /// Path to the well-known location of the host-meta document at a domain. /// private const string LocalHostMetaPath = "/.well-known/host-meta"; /// /// The pattern within a host-meta file to look for to obtain the URI to the XRDS document. /// private static readonly Regex HostMetaLink = new Regex(@"^Link: <(?.+?)>; rel=""describedby http://reltype.google.com/openid/xrd-op""; type=""application/xrds\+xml""$"); /// /// Initializes a new instance of the class. /// public HostMetaDiscoveryService() { this.TrustedHostMetaProxies = new List(); } /// /// Gets the set of URI templates to use to contact host-meta hosting proxies /// for domain discovery. /// public IList TrustedHostMetaProxies { get; private set; } /// /// Gets or sets a value indicating whether to trust Google to host domains' host-meta documents. /// /// /// This property is just a convenient mechanism for checking or changing the set of /// trusted host-meta proxies in the property. /// public bool UseGoogleHostedHostMeta { get { return this.TrustedHostMetaProxies.Contains(GoogleHostedHostMeta); } set { if (value != this.UseGoogleHostedHostMeta) { if (value) { this.TrustedHostMetaProxies.Add(GoogleHostedHostMeta); } else { this.TrustedHostMetaProxies.Remove(GoogleHostedHostMeta); } } } } #region IIdentifierDiscoveryService Members /// /// Performs discovery on the specified identifier. /// /// The identifier to perform discovery on. /// The means to place outgoing HTTP requests. /// if set to true, no further discovery services will be called for this identifier. /// /// A sequence of service endpoints yielded by discovery. Must not be null, but may be empty. /// public IEnumerable Discover(Identifier identifier, IDirectWebRequestHandler requestHandler, out bool abortDiscoveryChain) { abortDiscoveryChain = false; // Google Apps are always URIs -- not XRIs. var uriIdentifier = identifier as UriIdentifier; if (uriIdentifier == null) { return Enumerable.Empty(); } var results = new List(); string signingHost; using (var response = GetXrdsResponse(uriIdentifier, requestHandler, out signingHost)) { if (response != null) { try { var document = new XrdsDocument(XmlReader.Create(response.ResponseStream)); ValidateXmlDSig(document, uriIdentifier, response, signingHost); var xrds = GetXrdElements(document, uriIdentifier.Uri.Host); // Look for claimed identifier template URIs for an additional XRDS document. results.AddRange(GetExternalServices(xrds, uriIdentifier, requestHandler)); // If we couldn't find any claimed identifiers, look for OP identifiers. // Normally this would be the opposite (OP Identifiers take precedence over // claimed identifiers, but for Google Apps, XRDS' always have OP Identifiers // mixed in, which the OpenID spec mandate should eclipse Claimed Identifiers, // which would break positive assertion checks). if (results.Count == 0) { results.AddRange(xrds.CreateServiceEndpoints(uriIdentifier, uriIdentifier)); } abortDiscoveryChain = true; } catch (XmlException ex) { Logger.Yadis.ErrorFormat("Error while parsing XRDS document at {0} pointed to by host-meta: {1}", response.FinalUri, ex); } } } return results; } #endregion /// /// Gets the XRD elements that have a given CanonicalID. /// /// The XRDS document. /// The CanonicalID to match on. /// A sequence of XRD elements. private static IEnumerable GetXrdElements(XrdsDocument document, string canonicalId) { // filter to include only those XRD elements describing the host whose host-meta pointed us to this document. return document.XrdElements.Where(xrd => string.Equals(xrd.CanonicalID, canonicalId, StringComparison.Ordinal)); } /// /// Gets the described-by services in XRD elements. /// /// The XRDs to search. /// A sequence of services. private static IEnumerable GetDescribedByServices(IEnumerable xrds) { Contract.Requires(xrds != null); Contract.Ensures(Contract.Result>() != null); var describedBy = from xrd in xrds from service in xrd.SearchForServiceTypeUris(p => "http://www.iana.org/assignments/relation/describedby") select service; return describedBy; } /// /// Gets the services for an identifier that are described by an external XRDS document. /// /// The XRD elements to search for described-by services. /// The identifier under discovery. /// The request handler. /// The discovered services. private static IEnumerable GetExternalServices(IEnumerable xrds, UriIdentifier identifier, IDirectWebRequestHandler requestHandler) { Contract.Requires(xrds != null); Contract.Requires(identifier != null); Contract.Requires(requestHandler != null); Contract.Ensures(Contract.Result>() != null); var results = new List(); foreach (var serviceElement in GetDescribedByServices(xrds)) { var templateNode = serviceElement.Node.SelectSingleNode("google:URITemplate", serviceElement.XmlNamespaceResolver); var nextAuthorityNode = serviceElement.Node.SelectSingleNode("google:NextAuthority", serviceElement.XmlNamespaceResolver); if (templateNode != null) { Uri externalLocation = new Uri(templateNode.Value.Trim().Replace("{%uri}", Uri.EscapeDataString(identifier.Uri.AbsoluteUri))); string nextAuthority = nextAuthorityNode != null ? nextAuthorityNode.Value.Trim() : identifier.Uri.Host; try { using (var externalXrdsResponse = GetXrdsResponse(identifier, requestHandler, externalLocation)) { XrdsDocument externalXrds = new XrdsDocument(XmlReader.Create(externalXrdsResponse.ResponseStream)); ValidateXmlDSig(externalXrds, identifier, externalXrdsResponse, nextAuthority); results.AddRange(GetXrdElements(externalXrds, identifier).CreateServiceEndpoints(identifier, identifier)); } } catch (ProtocolException ex) { Logger.Yadis.WarnFormat("HTTP GET error while retrieving described-by XRDS document {0}: {1}", externalLocation.AbsoluteUri, ex); } catch (XmlException ex) { Logger.Yadis.ErrorFormat("Error while parsing described-by XRDS document {0}: {1}", externalLocation.AbsoluteUri, ex); } } } return results; } /// /// Validates the XML digital signature on an XRDS document. /// /// The XRDS document whose signature should be validated. /// The identifier under discovery. /// The response. /// The host name on the certificate that should be used to verify the signature in the XRDS. /// Thrown if the XRDS document has an invalid or a missing signature. [SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "XmlDSig", Justification = "xml")] private static void ValidateXmlDSig(XrdsDocument document, UriIdentifier identifier, IncomingWebResponse response, string signingHost) { Contract.Requires(document != null); Contract.Requires(identifier != null); Contract.Requires(response != null); var signatureNode = document.Node.SelectSingleNode("/xrds:XRDS/ds:Signature", document.XmlNamespaceResolver); ErrorUtilities.VerifyProtocol(signatureNode != null, OpenIdStrings.MissingElement, "Signature"); var signedInfoNode = signatureNode.SelectSingleNode("ds:SignedInfo", document.XmlNamespaceResolver); ErrorUtilities.VerifyProtocol(signedInfoNode != null, OpenIdStrings.MissingElement, "SignedInfo"); ErrorUtilities.VerifyProtocol( signedInfoNode.SelectSingleNode("ds:CanonicalizationMethod[@Algorithm='http://docs.oasis-open.org/xri/xrd/2009/01#canonicalize-raw-octets']", document.XmlNamespaceResolver) != null, OpenIdStrings.UnsupportedCanonicalizationMethod); ErrorUtilities.VerifyProtocol( signedInfoNode.SelectSingleNode("ds:SignatureMethod[@Algorithm='http://www.w3.org/2000/09/xmldsig#rsa-sha1']", document.XmlNamespaceResolver) != null, OpenIdStrings.UnsupportedSignatureMethod); var certNodes = signatureNode.Select("ds:KeyInfo/ds:X509Data/ds:X509Certificate", document.XmlNamespaceResolver); ErrorUtilities.VerifyProtocol(certNodes.Count > 0, OpenIdStrings.MissingElement, "X509Certificate"); var certs = certNodes.Cast().Select(n => new X509Certificate2(Convert.FromBase64String(n.Value.Trim()))).ToList(); // Verify that we trust the signer of the certificates. // Start by trying to validate just the certificate used to sign the XRDS document, // since we can do that with partial trust. Logger.OpenId.Debug("Verifying that we trust the certificate used to sign the discovery document."); if (!certs[0].Verify()) { // We couldn't verify just the signing certificate, so try to verify the whole certificate chain. try { Logger.OpenId.Debug("Verifying the whole certificate chain."); VerifyCertChain(certs); Logger.OpenId.Debug("Certificate chain verified."); } catch (SecurityException) { Logger.Yadis.Warn("Signing certificate verification failed and we have insufficient code access security permissions to perform certificate chain validation."); ErrorUtilities.ThrowProtocol(OpenIdStrings.X509CertificateNotTrusted); } } // Verify that the certificate is issued to the host on whom we are performing discovery. string hostName = certs[0].GetNameInfo(X509NameType.DnsName, false); ErrorUtilities.VerifyProtocol(string.Equals(hostName, signingHost, StringComparison.OrdinalIgnoreCase), OpenIdStrings.MisdirectedSigningCertificate, hostName, signingHost); // Verify the signature itself byte[] signature = Convert.FromBase64String(response.Headers["Signature"]); var provider = (RSACryptoServiceProvider)certs.First().PublicKey.Key; byte[] data = new byte[response.ResponseStream.Length]; response.ResponseStream.Seek(0, SeekOrigin.Begin); response.ResponseStream.Read(data, 0, data.Length); ErrorUtilities.VerifyProtocol(provider.VerifyData(data, "SHA1", signature), OpenIdStrings.InvalidDSig); } /// /// Verifies the cert chain. /// /// The certs. /// /// This must be in a method of its own because there is a LinkDemand on the /// method. By being in a method of its own, the caller of this method may catch a /// that is thrown if we're not running with full trust and execute /// an alternative plan. /// /// Thrown if the certificate chain is invalid or unverifiable. [SuppressMessage("Microsoft.Globalization", "CA1303:Do not pass literals as localized parameters", MessageId = "DotNetOpenAuth.Messaging.ErrorUtilities.ThrowProtocol(System.String,System.Object[])", Justification = "The localized portion is a string resource already."), SuppressMessage("Microsoft.Security", "CA2122:DoNotIndirectlyExposeMethodsWithLinkDemands", Justification = "By design")] private static void VerifyCertChain(List certs) { var chain = new X509Chain(); foreach (var cert in certs) { chain.Build(cert); } if (chain.ChainStatus.Length > 0) { ErrorUtilities.ThrowProtocol( string.Format( CultureInfo.CurrentCulture, OpenIdStrings.X509CertificateNotTrusted + " {0}", string.Join(", ", chain.ChainStatus.Select(status => status.StatusInformation).ToArray()))); } } /// /// Gets the XRDS HTTP response for a given identifier. /// /// The identifier. /// The request handler. /// The location of the XRDS document to retrieve. /// /// A HTTP response carrying an XRDS document. /// /// Thrown if the XRDS document could not be obtained. private static IncomingWebResponse GetXrdsResponse(UriIdentifier identifier, IDirectWebRequestHandler requestHandler, Uri xrdsLocation) { Contract.Requires(identifier != null); Contract.Requires(requestHandler != null); Contract.Requires(xrdsLocation != null); Contract.Ensures(Contract.Result() != null); var request = (HttpWebRequest)WebRequest.Create(xrdsLocation); request.CachePolicy = Yadis.IdentifierDiscoveryCachePolicy; request.Accept = ContentTypes.Xrds; var options = identifier.IsDiscoverySecureEndToEnd ? DirectWebRequestOptions.RequireSsl : DirectWebRequestOptions.None; var response = requestHandler.GetResponse(request, options).GetSnapshot(Yadis.MaximumResultToScan); if (!string.Equals(response.ContentType.MediaType, ContentTypes.Xrds, StringComparison.Ordinal)) { Logger.Yadis.WarnFormat("Host-meta pointed to XRDS at {0}, but Content-Type at that URL was unexpected value '{1}'.", xrdsLocation, response.ContentType); } return response; } /// /// Gets the XRDS HTTP response for a given identifier. /// /// The identifier. /// The request handler. /// The host name on the certificate that should be used to verify the signature in the XRDS. /// A HTTP response carrying an XRDS document, or null if one could not be obtained. /// Thrown if the XRDS document could not be obtained. private IncomingWebResponse GetXrdsResponse(UriIdentifier identifier, IDirectWebRequestHandler requestHandler, out string signingHost) { Contract.Requires(identifier != null); Contract.Requires(requestHandler != null); Uri xrdsLocation = this.GetXrdsLocation(identifier, requestHandler, out signingHost); if (xrdsLocation == null) { return null; } var response = GetXrdsResponse(identifier, requestHandler, xrdsLocation); return response; } /// /// Gets the location of the XRDS document that describes a given identifier. /// /// The identifier under discovery. /// The request handler. /// The host name on the certificate that should be used to verify the signature in the XRDS. /// An absolute URI, or null if one could not be determined. private Uri GetXrdsLocation(UriIdentifier identifier, IDirectWebRequestHandler requestHandler, out string signingHost) { Contract.Requires(identifier != null); Contract.Requires(requestHandler != null); using (var hostMetaResponse = this.GetHostMeta(identifier, requestHandler, out signingHost)) { if (hostMetaResponse == null) { return null; } using (var sr = hostMetaResponse.GetResponseReader()) { string line = sr.ReadLine(); Match m = HostMetaLink.Match(line); if (m.Success) { Uri location = new Uri(m.Groups["location"].Value); Logger.Yadis.InfoFormat("Found link to XRDS at {0} in host-meta document {1}.", location, hostMetaResponse.FinalUri); return location; } } Logger.Yadis.WarnFormat("Could not find link to XRDS in host-meta document: {0}", hostMetaResponse.FinalUri); return null; } } /// /// Gets the host-meta for a given identifier. /// /// The identifier. /// The request handler. /// The host name on the certificate that should be used to verify the signature in the XRDS. /// /// The host-meta response, or null if no host-meta document could be obtained. /// private IncomingWebResponse GetHostMeta(UriIdentifier identifier, IDirectWebRequestHandler requestHandler, out string signingHost) { Contract.Requires(identifier != null); Contract.Requires(requestHandler != null); foreach (var hostMetaProxy in this.GetHostMetaLocations(identifier)) { var hostMetaLocation = hostMetaProxy.GetProxy(identifier); var request = (HttpWebRequest)WebRequest.Create(hostMetaLocation); request.CachePolicy = Yadis.IdentifierDiscoveryCachePolicy; var options = DirectWebRequestOptions.AcceptAllHttpResponses; if (identifier.IsDiscoverySecureEndToEnd) { options |= DirectWebRequestOptions.RequireSsl; } var response = requestHandler.GetResponse(request, options).GetSnapshot(Yadis.MaximumResultToScan); try { if (response.Status == HttpStatusCode.OK) { Logger.Yadis.InfoFormat("Found host-meta for {0} at: {1}", identifier.Uri.Host, hostMetaLocation); signingHost = hostMetaProxy.GetSigningHost(identifier); return response; } else { Logger.Yadis.InfoFormat("Could not obtain host-meta for {0} from {1}", identifier.Uri.Host, hostMetaLocation); response.Dispose(); } } catch { response.Dispose(); throw; } } signingHost = null; return null; } /// /// Gets the URIs authorized to host host-meta documents on behalf of a given domain. /// /// The identifier. /// A sequence of URIs that MAY provide the host-meta for a given identifier. private IEnumerable GetHostMetaLocations(UriIdentifier identifier) { Contract.Requires(identifier != null); // First try the proxies, as they are considered more "secure" than the local // host-meta for a domain since the domain may be defaced. IEnumerable result = this.TrustedHostMetaProxies; // Finally, look for the local host-meta. UriBuilder localHostMetaBuilder = new UriBuilder(); localHostMetaBuilder.Scheme = identifier.IsDiscoverySecureEndToEnd || identifier.Uri.IsTransportSecure() ? Uri.UriSchemeHttps : Uri.UriSchemeHttp; localHostMetaBuilder.Host = identifier.Uri.Host; localHostMetaBuilder.Path = LocalHostMetaPath; result = result.Concat(new[] { new HostMetaProxy(localHostMetaBuilder.Uri.AbsoluteUri, identifier.Uri.Host) }); return result; } /// /// A description of a web server that hosts host-meta documents. /// [SuppressMessage("Microsoft.Design", "CA1034:NestedTypesShouldNotBeVisible", Justification = "By design")] public class HostMetaProxy { /// /// Initializes a new instance of the class. /// /// The proxy formatting string. /// The signing host formatting string. public HostMetaProxy(string proxyFormat, string signingHostFormat) { Contract.Requires(!String.IsNullOrEmpty(proxyFormat)); Contract.Requires(!String.IsNullOrEmpty(signingHostFormat)); this.ProxyFormat = proxyFormat; this.SigningHostFormat = signingHostFormat; } /// /// Gets the URL of the host-meta proxy. /// /// The absolute proxy URL, which may include {0} to be replaced with the host of the identifier to be discovered. public string ProxyFormat { get; private set; } /// /// Gets the formatting string to determine the expected host name on the certificate /// that is expected to be used to sign the XRDS document. /// /// /// Either a string literal, or a formatting string where these placeholders may exist: /// {0} the host on the identifier discovery was originally performed on; /// {1} the host on this proxy. /// public string SigningHostFormat { get; private set; } /// /// Gets the absolute proxy URI. /// /// The identifier being discovered. /// The an absolute URI. public virtual Uri GetProxy(UriIdentifier identifier) { Contract.Requires(identifier != null); return new Uri(string.Format(CultureInfo.InvariantCulture, this.ProxyFormat, Uri.EscapeDataString(identifier.Uri.Host))); } /// /// Gets the signing host URI. /// /// The identifier being discovered. /// A host name. public virtual string GetSigningHost(UriIdentifier identifier) { Contract.Requires(identifier != null); return string.Format(CultureInfo.InvariantCulture, this.SigningHostFormat, identifier.Uri.Host, this.GetProxy(identifier).Host); } /// /// Determines whether the specified is equal to the current . /// /// The to compare with the current . /// /// true if the specified is equal to the current ; otherwise, false. /// /// /// The parameter is null. /// public override bool Equals(object obj) { var other = obj as HostMetaProxy; if (other == null) { return false; } return this.ProxyFormat == other.ProxyFormat && this.SigningHostFormat == other.SigningHostFormat; } /// /// Serves as a hash function for a particular type. /// /// /// A hash code for the current . /// public override int GetHashCode() { return this.ProxyFormat.GetHashCode(); } } } }