Repository: zmap/zlint
Pull request #275 (2019-03-30 14:14:18)

lints: add lint for TorServiceDescriptorHash ext.
The new `lints/lint_ext_tor_service_descriptor_hash_invalid.go` lint validates that certificates with `.onion` subjects issued after July 28th, 2017 include a well formed `TorServiceDescriptor` extension with a `TorServiceDescriptorHash` entry for each onion eTLD+1 subject as expected by [CAB forum Ballot 201]( https://cabforum.org/2017/06/08/2427/).

E.g. a certificate with three onion subjects (`a.example.onion`, `b.example.onion`, `c.other-example.onion`) should have *two* `TorServiceDescriptorHash` entries in the `TorServiceDescriptor`
extension:

* One with URI `https://example.onion`
* One with URI `https://other-example.onion`

Only the eTLD+1 is relevant for Onion sites. Subdomains are resolved by the hidden server based on the HTTP layer `Host` header.

Unlike the [previous PR](https://github.com/zmap/zlint/pull/263) I opened for this lint the parsing work is now done in `zcrypto` (requiring a change to the `go.mod` version pin for `zlint`).  See https://github.com/zmap/zcrypto/pull/189 for more background/discussion

The new linter will return a fail result if:

* There is no `TorServiceDescriptor` extension present.
* There were no `TorServiceDescriptors` parsed by zcrypto
* There are `TorServiceDescriptorHash` entries with an invalid Onion URL (unparseable, missing hostname, non-HTTPS protocol, etc).
* There are `TorServiceDescriptorHash` entries with an unknown hash algorithm or incorrect hash bit length.
* There is a `TorServiceDescriptorHash` entry that doesn't correspond to an onion subject in the cert.
* There is an onion subject in the cert that doesn't correspond to a `TorServiceDescriptorHash`.

Resolves https://github.com/zmap/zlint/issues/14


Patch:
diff --git a/go.mod b/go.mod
index eb5829ef..1d4b3c62 100644
--- a/go.mod
+++ b/go.mod
@@ -4,7 +4,7 @@ require (
 	github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf
 	github.com/sirupsen/logrus v1.3.0
 	github.com/weppos/publicsuffix-go v0.4.0
-	github.com/zmap/zcrypto v0.0.0-20190109193801-0afe7b2ae1aa
+	github.com/zmap/zcrypto v0.0.0-20190329181646-dff83107394d
 	golang.org/x/net v0.0.0-20190301231341-16b79f2e4e95
 	golang.org/x/text v0.3.0
 )
diff --git a/go.sum b/go.sum
index 192ce1ca..5a7e4674 100644
--- a/go.sum
+++ b/go.sum
@@ -1,17 +1,29 @@
 github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf h1:eg0MeVzsP1G42dRafH3vf+al2vQIJU0YHX+1Tw87oco=
 github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk=
 github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
+github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
+github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
+github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/sirupsen/logrus v1.3.0 h1:hI/7Q+DtNZ2kINb6qt/lS+IyXnHQe9e90POfeewL/ME=
 github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/weppos/publicsuffix-go v0.4.0 h1:YSnfg3V65LcCFKtIGKGoBhkyKolEd0hlipcXaOjdnQw=
 github.com/weppos/publicsuffix-go v0.4.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k=
+github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE=
+github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is=
 github.com/zmap/zcrypto v0.0.0-20190109193801-0afe7b2ae1aa h1:L//saqlPtQn6/tZDXofljYdjat92i1kFf8CkrYsqOkE=
 github.com/zmap/zcrypto v0.0.0-20190109193801-0afe7b2ae1aa/go.mod h1:ybcKQRnmehW/YgiKy4llJYNfXJ14o3K5VGkbweCF1Mc=
+github.com/zmap/zcrypto v0.0.0-20190329181646-dff83107394d h1:EoQIHS1co8tkbljRLMADWiRAWLcKI02M/ZtPrAUxjHc=
+github.com/zmap/zcrypto v0.0.0-20190329181646-dff83107394d/go.mod h1:ix3q2kpLy0ibAuFXlr7qOhPKwFRRSjuynGuTR8EUPCk=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793 h1:u+LnwYTOOW7Ukr/fppxEb1Nwz0AtPflrblfvUudpo+I=
 golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
 golang.org/x/net v0.0.0-20190301231341-16b79f2e4e95 h1:fY7Dsw114eJN4boqzVSbpVHO6rTdhq6/GnXeu+PKnzU=
@@ -20,3 +32,4 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33 h1:I6FyU15t786LL7oL/hn43zqTu
 golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/lints/lint_ext_tor_service_descriptor_hash_invalid.go b/lints/lint_ext_tor_service_descriptor_hash_invalid.go
new file mode 100644
index 00000000..984c98a8
--- /dev/null
+++ b/lints/lint_ext_tor_service_descriptor_hash_invalid.go
@@ -0,0 +1,210 @@
+/*
+ * ZLint Copyright 2019 Regents of the University of Michigan
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not
+ * use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ * implied. See the License for the specific language governing
+ * permissions and limitations under the License.
+ */
+
+package lints
+
+import (
+	"fmt"
+	"net/url"
+	"strings"
+
+	"github.com/zmap/zcrypto/x509"
+	"github.com/zmap/zlint/util"
+)
+
+type torServiceDescHashInvalid struct{}
+
+func (l *torServiceDescHashInvalid) Initialize() error {
+	// There is nothing to initialize for a torServiceDescHashInvalid linter.
+	return nil
+}
+
+// CheckApplies returns true if the certificate is a subscriber certificate that
+// contains a subject name ending in `.onion`.
+func (l *torServiceDescHashInvalid) CheckApplies(c *x509.Certificate) bool {
+	return util.IsSubscriberCert(c) && util.CertificateSubjInTLD(c, onionTLD)
+}
+
+// failResult is a small utility function for creating a failed lint result.
+func failResult(format string, args ...interface{}) *LintResult {
+	return &LintResult{
+		Status:  Error,
+		Details: fmt.Sprintf(format, args...),
+	}
+}
+
+// torServiceDescExtName is a common string prefix used in many lint result
+// detail messages to identify the extension at fault.
+var torServiceDescExtName = fmt.Sprintf(
+	"TorServiceDescriptor extension (oid %s)",
+	util.BRTorServiceDescriptor.String())
+
+// lintOnionURL verifies that an Onion URI value from a TorServiceDescriptorHash
+// is:
+//
+// 1) a valid parseable url.
+// 2) a URL with a non-empty hostname
+// 3) a URL with an https:// protocol scheme
+//
+// If all of the above hold then nil is returned. If any of the above conditions
+// are not met an error lint result pointer is returned.
+func lintOnionURL(onion string) *LintResult {
+	if onionURL, err := url.Parse(onion); err != nil {
+		return failResult(
+			"%s contained "+
+				"TorServiceDescriptorHash object with invalid Onion URI",
+			torServiceDescExtName)
+	} else if onionURL.Host == "" {
+		return failResult(
+			"%s contained "+
+				"TorServiceDescriptorHash object with Onion URI missing a hostname",
+			torServiceDescExtName)
+	} else if onionURL.Scheme != "https" {
+		return failResult(
+			"%s contained "+
+				"TorServiceDescriptorHash object with Onion URI using a non-HTTPS "+
+				"protocol scheme",
+			torServiceDescExtName)
+	}
+	return nil
+}
+
+// Execute will lint the provided certificate. An Error LintResult will be
+// returned if:
+//
+//   1) There is no TorServiceDescriptor extension present.
+//   2) There were no TorServiceDescriptors parsed by zcrypto
+//   3) There are TorServiceDescriptorHash entries with an invalid Onion URL.
+//   4) There are TorServiceDescriptorHash entries with an unknown hash
+//      algorithm or incorrect hash bit length.
+//   5) There is a TorServiceDescriptorHash entry that doesn't correspond to
+//      an onion subject in the cert.
+//   6) There is an onion subject in the cert that doesn't correspond to
+//      a TorServiceDescriptorHash.
+func (l *torServiceDescHashInvalid) Execute(c *x509.Certificate) *LintResult {
+	// If the BRTorServiceDescriptor extension is missing return a lint error. We
+	// know the cert contains one or more `.onion` subjects because of
+	// `CheckApplies` and all such certs are expected to have this extension after
+	// util.CABV201Date.
+	if ext := util.GetExtFromCert(c, util.BRTorServiceDescriptor); ext == nil {
+		return failResult(
+			"certificate contained a %s domain but is missing a TorServiceDescriptor "+
+				"extension (oid %s)",
+			onionTLD, util.BRTorServiceDescriptor.String())
+	}
+
+	// The certificate should have at least one TorServiceDescriptorHash in the
+	// TorServiceDescriptor extension.
+	descriptors := c.TorServiceDescriptors
+	if len(descriptors) == 0 {
+		return failResult(
+			"certificate contained a %s domain but TorServiceDescriptor "+
+				"extension (oid %s) had no TorServiceDescriptorHash objects",
+			onionTLD, util.BRTorServiceDescriptor.String())
+	}
+
+	// Build a map of all the eTLD+1 onion subjects in the cert to compare against
+	// the service descriptors.
+	onionETLDPlusOneMap := make(map[string]string)
+	for _, subj := range append(c.DNSNames, c.Subject.CommonName) {
+		if !strings.HasSuffix(subj, onionTLD) {
+			continue
+		}
+		labels := strings.Split(subj, ".")
+		if len(labels) < 2 {
+			return failResult("certificate contained a %s domain with too few "+
+				"labels: %q",
+				onionTLD, subj)
+		}
+		eTLDPlusOne := strings.Join(labels[len(labels)-2:], ".")
+		onionETLDPlusOneMap[eTLDPlusOne] = subj
+	}
+
+	expectedHashBits := map[string]int{
+		"SHA256": 256,
+		"SHA384": 384,
+		"SHA512": 512,
+	}
+
+	// Build a map of onion hostname -> TorServiceDescriptorHash using the parsed
+	// TorServiceDescriptors from zcrypto.
+	descriptorMap := make(map[string]*x509.TorServiceDescriptorHash)
+	for _, descriptor := range descriptors {
+		// each descriptor's Onion URL must be valid
+		if errResult := lintOnionURL(descriptor.Onion); errResult != nil {
+			return errResult
+		}
+		// each descriptor should have a known hash algorithm and the correct
+		// corresponding size of hash.
+		if expectedBits, found := expectedHashBits[descriptor.AlgorithmName]; !found {
+			return failResult(
+				"%s contained a TorServiceDescriptorHash for Onion URI %q with an "+
+					"unknown hash algorithm",
+				torServiceDescExtName, descriptor.Onion)
+		} else if expectedBits != descriptor.HashBits {
+			return failResult(
+				"%s contained a TorServiceDescriptorHash with hash algorithm %q but "+
+					"only %d bits of hash not %d",
+				torServiceDescExtName, descriptor.AlgorithmName,
+				descriptor.HashBits, expectedBits)
+		}
+		// NOTE(@cpu): Throwing out the err result here because lintOnionURL already
+		//             ensured the URL is valid.
+		url, _ := url.Parse(descriptor.Onion)
+		hostname := url.Hostname()
+		// there should only be one TorServiceDescriptorHash for each Onion hostname.
+		if _, exists := descriptorMap[hostname]; exists {
+			return failResult(
+				"%s contained more than one TorServiceDescriptorHash for base "+
+					"Onion URI %q",
+				torServiceDescExtName, descriptor.Onion)
+		}
+		// there shouldn't be a TorServiceDescriptorHash for a Onion hostname that
+		// isn't an eTLD+1 in the certificate's subjects.
+		if _, found := onionETLDPlusOneMap[hostname]; !found {
+			return failResult(
+				"%s contained a TorServiceDescriptorHash with a hostname (%q) not "+
+					"present as a subject in the certificate",
+				torServiceDescExtName, hostname)
+		}
+		descriptorMap[hostname] = descriptor
+	}
+
+	// Check if any of the onion subjects in the certificate don't have
+	// a TorServiceDescriptorHash for the eTLD+1 in the descriptorMap.
+	for eTLDPlusOne, subjDomain := range onionETLDPlusOneMap {
+		if _, found := descriptorMap[eTLDPlusOne]; !found {
+			return failResult(
+				"%s subject domain name %q does not have a corresponding "+
+					"TorServiceDescriptorHash for its eTLD+1",
+				onionTLD, subjDomain)
+		}
+	}
+
+	// Everything checks out!
+	return &LintResult{
+		Status: Pass,
+	}
+}
+
+func init() {
+	RegisterLint(&Lint{
+		Name:          "ext_tor_service_descriptor_hash_invalid",
+		Description:   "certificates with .onion names need valid TorServiceDescriptors in extension",
+		Citation:      "BRS: Ballot 201",
+		Source:        CABFBaselineRequirements,
+		EffectiveDate: util.CABV201Date,
+		Lint:          &torServiceDescHashInvalid{},
+	})
+}
diff --git a/util/oid.go b/util/oid.go
index e0ea1e90..082e1b3b 100644
--- a/util/oid.go
+++ b/util/oid.go
@@ -52,6 +52,7 @@ var (
 	BRDomainValidatedOID       = asn1.ObjectIdentifier{2, 23, 140, 1, 2, 1} // CA/B BR Domain-Validated
 	BROrganizationValidatedOID = asn1.ObjectIdentifier{2, 23, 140, 1, 2, 2} // CA/B BR Organization-Validated
 	BRIndividualValidatedOID   = asn1.ObjectIdentifier{2, 23, 140, 1, 2, 3} // CA/B BR Individual-Validated
+	BRTorServiceDescriptor     = asn1.ObjectIdentifier{2, 23, 140, 1, 31}   // CA/B BR Tor Service Descriptor
 	//X.500 attribute types
 	CommonNameOID             = asn1.ObjectIdentifier{2, 5, 4, 3}
 	SurnameOID                = asn1.ObjectIdentifier{2, 5, 4, 4}
@@ -65,6 +66,10 @@ var (
 	BusinessOID               = asn1.ObjectIdentifier{2, 5, 4, 15}
 	PostalCodeOID             = asn1.ObjectIdentifier{2, 5, 4, 17}
 	GivenNameOID              = asn1.ObjectIdentifier{2, 5, 4, 42}
+	// Hash algorithms - see https://golang.org/src/crypto/x509/x509.go
+	SHA256OID = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}
+	SHA384OID = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 2}
+	SHA512OID = asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 3}
 	// other OIDs
 	OidRSASSAPSS               = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 10}
 	AnyPolicyOID               = asn1.ObjectIdentifier{2, 5, 29, 32, 0}
diff --git a/util/time.go b/util/time.go
index 192ad864..14b14648 100644
--- a/util/time.go
+++ b/util/time.go
@@ -52,6 +52,7 @@ var (
 	CABV148Date                 = time.Date(2017, time.June, 8, 0, 0, 0, 0, time.UTC)
 	EtsiEn319_412_5_V2_2_1_Date = time.Date(2017, time.November, 1, 0, 0, 0, 0, time.UTC)
 	OnionOnlyEVDate             = time.Date(2015, time.May, 1, 0, 0, 0, 0, time.UTC)
+	CABV201Date                 = time.Date(2017, time.July, 28, 0, 0, 0, 0, time.UTC)
 )
 
 func FindTimeType(firstDate, secondDate asn1.RawValue) (int, int) {
diff --git a/vendor/github.com/zmap/zcrypto/x509/extensions.go b/vendor/github.com/zmap/zcrypto/x509/extensions.go
index 31169fd2..91c21d4c 100644
--- a/vendor/github.com/zmap/zcrypto/x509/extensions.go
+++ b/vendor/github.com/zmap/zcrypto/x509/extensions.go
@@ -47,6 +47,7 @@ type CertificateExtensions struct {
 	AuthorityInfoAccess            *AuthorityInfoAccess             `json:"authority_info_access,omitempty"`
 	IsPrecert                      IsPrecert                        `json:"ct_poison,omitempty"`
 	SignedCertificateTimestampList []*ct.SignedCertificateTimestamp `json:"signed_certificate_timestamps,omitempty"`
+	TorServiceDescriptors          []*TorServiceDescriptorHash      `json:"tor_service_descriptors,omitempty"`
 }
 
 type UnknownCertificateExtensions []pkix.Extension
@@ -748,6 +749,8 @@ func (c *Certificate) jsonifyExtensions() (*CertificateExtensions, UnknownCertif
 			exts.SignedCertificateTimestampList = c.SignedCertificateTimestampList
 		} else if e.Id.Equal(oidExtensionCTPrecertificatePoison) {
 			exts.IsPrecert = true
+		} else if e.Id.Equal(oidBRTorServiceDescriptor) {
+			exts.TorServiceDescriptors = c.TorServiceDescriptors
 		} else {
 			// Unknown extension
 			unk = append(unk, e)
diff --git a/vendor/github.com/zmap/zcrypto/x509/tor_service_descriptor.go b/vendor/github.com/zmap/zcrypto/x509/tor_service_descriptor.go
new file mode 100644
index 00000000..366bd959
--- /dev/null
+++ b/vendor/github.com/zmap/zcrypto/x509/tor_service_descriptor.go
@@ -0,0 +1,158 @@
+package x509
+
+import (
+	"encoding/asn1"
+	"github.com/zmap/zcrypto/x509/pkix"
+)
+
+var (
+	// oidBRTorServiceDescriptor is the assigned OID for the CAB Forum Tor Service
+	// Descriptor Hash extension (see EV Guidelines Appendix F)
+	oidBRTorServiceDescriptor = asn1.ObjectIdentifier{2, 23, 140, 1, 31}
+)
+
+// TorServiceDescriptorHash is a structure corrsponding to the
+// TorServiceDescriptorHash SEQUENCE described in Appendix F ("Issuance of
+// Certificates for .onion Domain Names").
+//
+// Each TorServiceDescriptorHash holds an onion URI (a utf8 string with the
+// .onion address that was validated), a hash algorithm name (computed based on
+// the pkix.AlgorithmIdentifier in the TorServiceDescriptorHash), the hash bytes
+// (computed over the DER encoding of the ASN.1 SubjectPublicKey of the .onion
+// service), and the number of bits in the hash bytes.
+type TorServiceDescriptorHash struct {
+	Onion         string                   `json:"onion"`
+	Algorithm     pkix.AlgorithmIdentifier `json:"-"`
+	AlgorithmName string                   `json:"algorithm_name"`
+	Hash          CertificateFingerprint   `json:"hash"`
+	HashBits      int                      `json:"hash_bits"`
+}
+
+// parseTorServiceDescriptorSyntax parses the given pkix.Extension (assumed to
+// have OID == oidBRTorServiceDescriptor) and returns a slice of parsed
+// TorServiceDescriptorHash objects, or an error. An error will be returned if
+// there are any structural errors related to the ASN.1 content (wrong tags,
+// trailing data, missing fields, etc).
+func parseTorServiceDescriptorSyntax(ext pkix.Extension) ([]*TorServiceDescriptorHash, error) {
+	// TorServiceDescriptorSyntax ::=
+	//    SEQUENCE ( 1..MAX ) of TorServiceDescriptorHash
+	var seq asn1.RawValue
+	rest, err := asn1.Unmarshal(ext.Value, &seq)
+	if err != nil {
+		return nil, asn1.SyntaxError{
+			Msg: "unable to unmarshal outer TorServiceDescriptor SEQUENCE",
+		}
+	}
+	if len(rest) != 0 {
+		return nil, asn1.SyntaxError{
+			Msg: "trailing data after outer TorServiceDescriptor SEQUENCE",
+		}
+	}
+	if seq.Tag != asn1.TagSequence || seq.Class != asn1.ClassUniversal || !seq.IsCompound {
+		return nil, asn1.SyntaxError{
+			Msg: "invalid outer TorServiceDescriptor SEQUENCE",
+		}
+	}
+
+	var descriptors []*TorServiceDescriptorHash
+	rest = seq.Bytes
+	for len(rest) > 0 {
+		var descriptor *TorServiceDescriptorHash
+		descriptor, rest, err = parseTorServiceDescriptorHash(rest)
+		if err != nil {
+			return nil, err
+		}
+		descriptors = append(descriptors, descriptor)
+	}
+	return descriptors, nil
+}
+
+// parseTorServiceDescriptorHash unmarshals a SEQUENCE from the provided data
+// and parses a TorServiceDescriptorHash using the data contained in the
+// sequence. The TorServiceDescriptorHash object and the remaining data are
+// returned if no error occurs.
+func parseTorServiceDescriptorHash(data []byte) (*TorServiceDescriptorHash, []byte, error) {
+	// TorServiceDescriptorHash:: = SEQUENCE {
+	//   onionURI UTF8String
+	//   algorithm AlgorithmIdentifier
+	//   subjectPublicKeyHash BIT STRING
+	// }
+	var outerSeq asn1.RawValue
+	var err error
+	data, err = asn1.Unmarshal(data, &outerSeq)
+	if err != nil {
+		return nil, data, asn1.SyntaxError{
+			Msg: "error unmarshaling TorServiceDescriptorHash SEQUENCE",
+		}
+	}
+	if outerSeq.Tag != asn1.TagSequence ||
+		outerSeq.Class != asn1.ClassUniversal ||
+		!outerSeq.IsCompound {
+		return nil, data, asn1.SyntaxError{
+			Msg: "TorServiceDescriptorHash missing compound SEQUENCE tag",
+		}
+	}
+	fieldData := outerSeq.Bytes
+
+	// Unmarshal and verify the structure of the onionURI UTF8String field.
+	var rawOnionURI asn1.RawValue
+	fieldData, err = asn1.Unmarshal(fieldData, &rawOnionURI)
+	if err != nil {
+		return nil, data, asn1.SyntaxError{
+			Msg: "error unmarshaling TorServiceDescriptorHash onionURI",
+		}
+	}
+	if rawOnionURI.Tag != asn1.TagUTF8String ||
+		rawOnionURI.Class != asn1.ClassUniversal ||
+		rawOnionURI.IsCompound {
+		return nil, data, asn1.SyntaxError{
+			Msg: "TorServiceDescriptorHash missing non-compound UTF8String tag",
+		}
+	}
+
+	// Unmarshal and verify the structure of the algorithm UTF8String field.
+	var algorithm pkix.AlgorithmIdentifier
+	fieldData, err = asn1.Unmarshal(fieldData, &algorithm)
+	if err != nil {
+		return nil, nil, asn1.SyntaxError{
+			Msg: "error unmarshaling TorServiceDescriptorHash algorithm",
+		}
+	}
+
+	var algorithmName string
+	if algorithm.Algorithm.Equal(oidSHA256) {
+		algorithmName = "SHA256"
+	} else if algorithm.Algorithm.Equal(oidSHA384) {
+		algorithmName = "SHA384"
+	} else if algorithm.Algorithm.Equal(oidSHA512) {
+		algorithmName = "SHA512"
+	} else {
+		algorithmName = "Unknown"
+	}
+
+	// Unmarshal and verify the structure of the Subject Public Key Hash BitString
+	// field.
+	var spkh asn1.BitString
+	fieldData, err = asn1.Unmarshal(fieldData, &spkh)
+	if err != nil {
+		return nil, data, asn1.SyntaxError{
+			Msg: "error unmarshaling TorServiceDescriptorHash Hash",
+		}
+	}
+
+	// There should be no trailing data after the TorServiceDescriptorHash
+	// SEQUENCE.
+	if len(fieldData) > 0 {
+		return nil, data, asn1.SyntaxError{
+			Msg: "trailing data after TorServiceDescriptorHash",
+		}
+	}
+
+	return &TorServiceDescriptorHash{
+		Onion:         string(rawOnionURI.Bytes),
+		Algorithm:     algorithm,
+		AlgorithmName: algorithmName,
+		HashBits:      spkh.BitLength,
+		Hash:          CertificateFingerprint(spkh.Bytes),
+	}, data, nil
+}
diff --git a/vendor/github.com/zmap/zcrypto/x509/x509.go b/vendor/github.com/zmap/zcrypto/x509/x509.go
index 969c4eb6..67d0d052 100644
--- a/vendor/github.com/zmap/zcrypto/x509/x509.go
+++ b/vendor/github.com/zmap/zcrypto/x509/x509.go
@@ -817,9 +817,14 @@ type Certificate struct {
 	parsedDNSNames []ParsedDomainName
 	// Used to speed up the zlint checks. Populated by the GetParsedCommonName method
 	parsedCommonName *ParsedDomainName
+
+	// CAB Forum Tor Service Descriptor Hash Extensions (see EV Guidelines
+	// Appendix F)
+	TorServiceDescriptors []*TorServiceDescriptorHash
 }
 
-// ParsedDomainName is a structure holding a parsed domain name (CommonName or DNS SAN) and a parsing error.
+// ParsedDomainName is a structure holding a parsed domain name (CommonName or
+// DNS SAN) and a parsing error.
 type ParsedDomainName struct {
 	DomainString string
 	ParsedDomain *publicsuffix.DomainName
@@ -1904,6 +1909,12 @@ func parseCertificate(in *certificate) (*Certificate, error) {
 			} else {
 				return nil, UnhandledCriticalExtension{e.Id, "Malformed precert poison"}
 			}
+		} else if e.Id.Equal(oidBRTorServiceDescriptor) {
+			descs, err := parseTorServiceDescriptorSyntax(e)
+			if err != nil {
+				return nil, err
+			}
+			out.TorServiceDescriptors = descs
 		}
 
 		//if e.Critical {
@@ -2858,7 +2869,7 @@ func ParseCertificateRequest(asn1Data []byte) (*CertificateRequest, error) {
 
 func parseCertificateRequest(in *certificateRequest) (*CertificateRequest, error) {
 	out := &CertificateRequest{
-		Raw: in.Raw,
+		Raw:                      in.Raw,
 		RawTBSCertificateRequest: in.TBSCSR.Raw,
 		RawSubjectPublicKeyInfo:  in.TBSCSR.PublicKey.Raw,
 		RawSubject:               in.TBSCSR.Subject.FullBytes,
diff --git a/vendor/modules.txt b/vendor/modules.txt
index 5f98bbbe..a4d329a6 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -6,7 +6,7 @@ github.com/konsorten/go-windows-terminal-sequences
 github.com/sirupsen/logrus
 # github.com/weppos/publicsuffix-go v0.4.0
 github.com/weppos/publicsuffix-go/publicsuffix
-# github.com/zmap/zcrypto v0.0.0-20190109193801-0afe7b2ae1aa
+# github.com/zmap/zcrypto v0.0.0-20190329181646-dff83107394d
 github.com/zmap/zcrypto/x509
 github.com/zmap/zcrypto/x509/pkix
 github.com/zmap/zcrypto/json


Test patch:
diff --git a/lints/lint_ext_tor_service_descriptor_hash_invalid_test.go b/lints/lint_ext_tor_service_descriptor_hash_invalid_test.go
new file mode 100644
index 00000000..db3935fc
--- /dev/null
+++ b/lints/lint_ext_tor_service_descriptor_hash_invalid_test.go
@@ -0,0 +1,75 @@
+package lints
+
+import (
+	"fmt"
+	"testing"
+)
+
+func TestTorDescHashInvalid(t *testing.T) {
+	testCases := []struct {
+		Name            string
+		InputFilename   string
+		ExpectedResult  LintStatus
+		ExpectedDetails string
+	}{
+		{
+			Name:           "Onion subject, no service descriptor extension, before util.CABV201Date",
+			InputFilename:  "dnsNameOnionTLD.pem",
+			ExpectedResult: NE,
+		},
+		{
+			Name:            "Onion subject, no service descriptor extension, after util.CABV201Date",
+			InputFilename:   "onionSANEV.pem",
+			ExpectedResult:  Error,
+			ExpectedDetails: "certificate contained a .onion domain but is missing a TorServiceDescriptor extension (oid 2.23.140.1.31)",
+		},
+		{
+			Name:            "Onion subject, bad service descriptor, unknown hash algorithm",
+			InputFilename:   "onionSANBadServDescUnknownHashAlg.pem",
+			ExpectedResult:  Error,
+			ExpectedDetails: `TorServiceDescriptor extension (oid 2.23.140.1.31) contained a TorServiceDescriptorHash for Onion URI "https://zmap.onion" with an unknown hash algorithm`,
+		},
+		{
+			Name:            "Onion subject, bad service descriptor, missing hostname",
+			InputFilename:   "onionSANBadServDescInvalidUTF8OnionURI.pem",
+			ExpectedResult:  Error,
+			ExpectedDetails: `TorServiceDescriptor extension (oid 2.23.140.1.31) contained TorServiceDescriptorHash object with Onion URI missing a hostname`,
+		},
+		{
+			Name:            "Onion subject, bad service descriptor, hash alg and hash bit len mismatch",
+			InputFilename:   "onionSANBadServDescHashMismatch.pem",
+			ExpectedResult:  Error,
+			ExpectedDetails: `TorServiceDescriptor extension (oid 2.23.140.1.31) contained a TorServiceDescriptorHash with hash algorithm "SHA256" but only 128 bits of hash not 256`,
+		},
+		{
+			Name:            "Multiple Onion subjects, one missing service descriptor hash entry",
+			InputFilename:   "onionSANMissingServDescHash.pem",
+			ExpectedResult:  Error,
+			ExpectedDetails: `.onion subject domain name "missing.onion" does not have a corresponding TorServiceDescriptorHash for its eTLD+1`,
+		},
+		{
+			Name:            "More service descriptor hash entries than Onion subjects",
+			InputFilename:   "onionSANTooManyServDesc.pem",
+			ExpectedResult:  Error,
+			ExpectedDetails: `TorServiceDescriptor extension (oid 2.23.140.1.31) contained a TorServiceDescriptorHash with a hostname ("other.onion") not present as a subject in the certificate`,
+		},
+		{
+			Name:           "Onion subject, valid service descriptor extension",
+			InputFilename:  "onionSANGoodServDesc.pem",
+			ExpectedResult: Pass,
+		},
+	}
+
+	for _, tc := range testCases {
+		t.Run(tc.Name, func(t *testing.T) {
+			inputPath := fmt.Sprintf("%s%s", testCaseDir, tc.InputFilename)
+			result := Lints["ext_tor_service_descriptor_hash_invalid"].Execute(ReadCertificate(inputPath))
+			if result.Status != tc.ExpectedResult {
+				t.Errorf("expected result %v was %v", tc.ExpectedResult, result.Status)
+			}
+			if result.Details != tc.ExpectedDetails {
+				t.Errorf("expected result details %q was %q", tc.ExpectedDetails, result.Details)
+			}
+		})
+	}
+}
diff --git a/testlint/testCerts/onionSANBadServDescHashMismatch.pem b/testlint/testCerts/onionSANBadServDescHashMismatch.pem
new file mode 100644
index 00000000..691211dc
--- /dev/null
+++ b/testlint/testCerts/onionSANBadServDescHashMismatch.pem
@@ -0,0 +1,12 @@
+-----BEGIN CERTIFICATE-----
+MIIBsDCCAVqgAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAxMNWm1h
+cCBPbmlvbiBDQTAeFw0xOTAzMDIyMDUxNTNaFw0yMDAzMDIyMDUxNTNaMBIxEDAO
+BgNVBAMTB3ptYXAuaW8wXDANBgkqhkiG9w0BAQEFAANLADBIAkEA9LSBpCR/ZS9A
+fb90JwNh0a+h32VM2j04fmH0S3j6WdQJ+icN6HRO7hNWeZsM0Bh5Pz/VFEzd5Rce
+Ad6aXPPoRQIDAQABo4GTMIGQMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
+AjAMBgNVHRMBAf8EAjAAMB4GA1UdEQQXMBWCB3ptYXAuaW+CCnptYXAub25pb24w
+QQYFZ4EMAR8EODA2MDQMEmh0dHBzOi8vem1hcC5vbmlvbjALBglghkgBZQMEAgED
+EQDHSfWySZyPZVwZsz/5PgN7MA0GCSqGSIb3DQEBCwUAA0EA0cRWgbg8FMj5JvQO
+H9zLjU+5FblYNvcjOCU7QHhbKguEH4DW0WvXD0BxVPtEvR9kDHcXO1QZtkJEIuKb
+M3+R2Q==
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/onionSANBadServDescInvalidUTF8OnionURI.pem b/testlint/testCerts/onionSANBadServDescInvalidUTF8OnionURI.pem
new file mode 100644
index 00000000..cebc9c79
--- /dev/null
+++ b/testlint/testCerts/onionSANBadServDescInvalidUTF8OnionURI.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIIB+DCCAaKgAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAxMNWm1h
+cCBPbmlvbiBDQTAeFw0xOTAzMDIyMTAwNDZaFw0yMDAzMDIyMTAwNDZaMBIxEDAO
+BgNVBAMTB3ptYXAuaW8wXDANBgkqhkiG9w0BAQEFAANLADBIAkEAq3JDkIK5MbLF
+skNiFy9CZT+j6HvoA6WVVGHk0c2HZChTqdX/QpgFtnRMRqqvmHhx6mMrbn6WNhRe
+GWQhstg8BwIDAQABo4HbMIHYMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
+AjAMBgNVHRMBAf8EAjAAMB4GA1UdEQQXMBWCB3ptYXAuaW+CCnptYXAub25pb24w
+gYgGBWeBDAEfBH8wfTBEDBJodHRwczovL3ptYXAub25pb24wCwYJYIZIAWUDBAIB
+AyEAx0n1skmcj2VcGbM/+T4De3t9vkcqrGJ4MHGwObhmOFwwNQwD//79MAsGCWCG
+SAFlAwQCAQMhAMdJ9bJJnI9lXBmzP/k+A3t7fb5HKqxieDBxsDm4ZjhcMA0GCSqG
+SIb3DQEBCwUAA0EAqIeqhpeNoANS+XTdDFA1P3TTPDKUWO4X1UH9A3BrTzBa1YB2
+ff7ktl88BaxFY/MgNJX6F1Arx4ISZjqMF5z/zg==
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/onionSANBadServDescUnknownHashAlg.pem b/testlint/testCerts/onionSANBadServDescUnknownHashAlg.pem
new file mode 100644
index 00000000..a96c5651
--- /dev/null
+++ b/testlint/testCerts/onionSANBadServDescUnknownHashAlg.pem
@@ -0,0 +1,12 @@
+-----BEGIN CERTIFICATE-----
+MIIBwDCCAWqgAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAxMNWm1h
+cCBPbmlvbiBDQTAeFw0xOTAzMDIyMDQ5MzNaFw0yMDAzMDIyMDQ5MzNaMBIxEDAO
+BgNVBAMTB3ptYXAuaW8wXDANBgkqhkiG9w0BAQEFAANLADBIAkEA3KvDJuIkMo/k
+YLqg2Fdy/2nrWAyACQnOqy2owdoVHca0aDr/MWbPQhdLfS7gwPdRPQ8HASJ75F3O
+dswMcdz2/QIDAQABo4GjMIGgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
+AjAMBgNVHRMBAf8EAjAAMB4GA1UdEQQXMBWCB3ptYXAuaW+CCnptYXAub25pb24w
+UQYFZ4EMAR8ESDBGMEQMEmh0dHBzOi8vem1hcC5vbmlvbjALBglghkgBZQMEAmMD
+IQDHSfWySZyPZVwZsz/5PgN7e32+RyqsYngwcbA5uGY4XDANBgkqhkiG9w0BAQsF
+AANBACxGPwrvVZYcE6XavNahcw930kNzMF5jPS/VxpJAMYOwpc6U3L1y/3Hj/ZG3
+60qjzf1P1bkZQ4BwqXs4S6IyFlo=
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/onionSANGoodServDesc.pem b/testlint/testCerts/onionSANGoodServDesc.pem
new file mode 100644
index 00000000..149d0e96
--- /dev/null
+++ b/testlint/testCerts/onionSANGoodServDesc.pem
@@ -0,0 +1,12 @@
+-----BEGIN CERTIFICATE-----
+MIIBwDCCAWqgAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAxMNWm1h
+cCBPbmlvbiBDQTAeFw0xOTAzMDIyMDIxMzNaFw0yMDAzMDIyMDIxMzNaMBIxEDAO
+BgNVBAMTB3ptYXAuaW8wXDANBgkqhkiG9w0BAQEFAANLADBIAkEA2x1KoWD5gDf5
+lKDYjExTDaaOeruOi/V9txhpMyeFbZA0WsMkzabCDHdDSsXm8CdgCMqtEGWmOqRi
+ToBMacdxGwIDAQABo4GjMIGgMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
+AjAMBgNVHRMBAf8EAjAAMB4GA1UdEQQXMBWCB3ptYXAuaW+CCnptYXAub25pb24w
+UQYFZ4EMAR8ESDBGMEQMEmh0dHBzOi8vem1hcC5vbmlvbjALBglghkgBZQMEAgED
+IQDHSfWySZyPZVwZsz/5PgN7e32+RyqsYngwcbA5uGY4XDANBgkqhkiG9w0BAQsF
+AANBAHbF1tBviktHsHa/kAFo3yh5hS2K35cY6hzdnlEWAWkGjUD8zlHvpq05W2SN
+fDrIZvN961P2fOQE9/JoaetonSg=
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/onionSANMissingServDescHash.pem b/testlint/testCerts/onionSANMissingServDescHash.pem
new file mode 100644
index 00000000..7b5a97d9
--- /dev/null
+++ b/testlint/testCerts/onionSANMissingServDescHash.pem
@@ -0,0 +1,12 @@
+-----BEGIN CERTIFICATE-----
+MIIBzzCCAXmgAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAxMNWm1h
+cCBPbmlvbiBDQTAeFw0xOTAzMDIyMDU0NDBaFw0yMDAzMDIyMDU0NDBaMBIxEDAO
+BgNVBAMTB3ptYXAuaW8wXDANBgkqhkiG9w0BAQEFAANLADBIAkEAyhMFSI9h3qP7
+DR/luYGBrqeBTmTl4pvs45tjx5I9PkZjNB+Cc+qHChHgl15Rh/f2J0fn+RVx53bE
+btTumyx8awIDAQABo4GyMIGvMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
+AjAMBgNVHRMBAf8EAjAAMC0GA1UdEQQmMCSCB3ptYXAuaW+CCnptYXAub25pb26C
+DW1pc3Npbmcub25pb24wUQYFZ4EMAR8ESDBGMEQMEmh0dHBzOi8vem1hcC5vbmlv
+bjALBglghkgBZQMEAgEDIQDHSfWySZyPZVwZsz/5PgN7e32+RyqsYngwcbA5uGY4
+XDANBgkqhkiG9w0BAQsFAANBADR6hZbLYaEEeBdC5fmx5goz9wlK00PXVueX15ut
+eOIWgGYbBhnZvNuN+IdrmFrvaoxPsWTp68Ny9TB6eawdKgY=
+-----END CERTIFICATE-----
diff --git a/testlint/testCerts/onionSANTooManyServDesc.pem b/testlint/testCerts/onionSANTooManyServDesc.pem
new file mode 100644
index 00000000..68788717
--- /dev/null
+++ b/testlint/testCerts/onionSANTooManyServDesc.pem
@@ -0,0 +1,13 @@
+-----BEGIN CERTIFICATE-----
+MIICCjCCAbSgAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwGDEWMBQGA1UEAxMNWm1h
+cCBPbmlvbiBDQTAeFw0xOTAzMDIyMTA4MDFaFw0yMDAzMDIyMTA4MDFaMBIxEDAO
+BgNVBAMTB3ptYXAuaW8wXDANBgkqhkiG9w0BAQEFAANLADBIAkEA1u834LbN3zEI
+cE+86tj1s1VL12nOmkH3VwFsO2JwEMZ1Q+vh7J0gNCug3h/GI8zSNvQ2mQPDKQoG
+J7smE+/8DQIDAQABo4HtMIHqMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
+AjAMBgNVHRMBAf8EAjAAMB4GA1UdEQQXMBWCB3ptYXAuaW+CCnptYXAub25pb24w
+gZoGBWeBDAEfBIGQMIGNMEQMEmh0dHBzOi8vem1hcC5vbmlvbjALBglghkgBZQME
+AgEDIQDHSfWySZyPZVwZsz/5PgN7e32+RyqsYngwcbA5uGY4XDBFDBNodHRwczov
+L290aGVyLm9uaW9uMAsGCWCGSAFlAwQCAQMhAMdJ9bJJnI9lXBmzP/k+A3t7fb5H
+KqxieDBxsDm4ZjhcMA0GCSqGSIb3DQEBCwUAA0EATKrA0RcdTW8BHohKLcB3Zxok
+r7AqvWwk38y5LCWtD+QLAknG2roT6sgV/5npeCOaW4gEgUPc0JxYYDD2ihOZwg==
+-----END CERTIFICATE-----