blob: 3ef67b05a91753ed2b1ea52e3c7486a5b69fac05 [file] [log] [blame] [edit]
// Copyright 2025 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
// This test uses Netflix's BetterTLS test suite to test the crypto/x509
// path building and name constraint validation.
//
// The test data in JSON form is around 31MB, so we fetch the BetterTLS
// go module and use it to generate the JSON data on-the-fly in a tmp dir.
//
// For more information, see:
// https://github.com/netflix/bettertls
// https://netflixtechblog.com/bettertls-c9915cd255c0
package x509
import (
"crypto/internal/cryptotest"
"encoding/base64"
"encoding/json"
"internal/testenv"
"os"
"path/filepath"
"testing"
)
// TestBetterTLS runs the "pathbuilding" and "nameconstraints" suites of
// BetterTLS.
//
// The test cases in the pathbuilding suite are designed to test edge-cases
// for path building and validation. In particular, the ["chain of pain"][0]
// scenario where a validator treats path building as an operation with
// a single possible outcome, instead of many.
//
// The test cases in the nameconstraints suite are designed to test edge-cases
// for name constraint parsing and validation.
//
// [0]: https://medium.com/@sleevi_/path-building-vs-path-verifying-the-chain-of-pain-9fbab861d7d6
func TestBetterTLS(t *testing.T) {
testenv.SkipIfShortAndSlow(t)
data, roots := betterTLSTestData(t)
for _, suite := range []string{"pathbuilding", "nameconstraints"} {
t.Run(suite, func(t *testing.T) {
runTestSuite(t, suite, &data, roots)
})
}
}
func runTestSuite(t *testing.T, suiteName string, data *betterTLS, roots *CertPool) {
suite, exists := data.Suites[suiteName]
if !exists {
t.Fatalf("missing %s suite", suiteName)
}
t.Logf(
"running %s test suite with %d test cases",
suiteName, len(suite.TestCases))
for _, tc := range suite.TestCases {
t.Logf("testing %s test case %d", suiteName, tc.ID)
certsDER, err := tc.Certs()
if err != nil {
t.Fatalf(
"failed to decode certificates for test case %d: %v",
tc.ID, err)
}
if len(certsDER) == 0 {
t.Fatalf("test case %d has no certificates", tc.ID)
}
eeCert, err := ParseCertificate(certsDER[0])
if err != nil {
// Several constraint test cases contain invalid end-entity
// certificate extensions that we reject ahead of verification
// time. We consider this a pass and skip further processing.
//
// For example, a SAN with a uniformResourceIdentifier general name
// containing the value `"http://foo.bar, DNS:test.localhost"`, or
// an iPAddress general name of the wrong length.
if suiteName == "nameconstraints" && tc.Expected == expectedReject {
t.Logf(
"skipping expected reject test case %d "+
"- end entity certificate parse error: %v",
tc.ID, err)
continue
}
t.Fatalf(
"failed to parse end entity certificate for test case %d: %v",
tc.ID, err)
}
intermediates := NewCertPool()
for i, certDER := range certsDER[1:] {
cert, err := ParseCertificate(certDER)
if err != nil {
t.Fatalf(
"failed to parse intermediate certificate %d for test case %d: %v",
i+1, tc.ID, err)
}
intermediates.AddCert(cert)
}
_, err = eeCert.Verify(VerifyOptions{
Roots: roots,
Intermediates: intermediates,
DNSName: tc.Hostname,
KeyUsages: []ExtKeyUsage{ExtKeyUsageServerAuth},
})
switch tc.Expected {
case expectedAccept:
if err != nil {
t.Errorf(
"test case %d failed: expected success, got error: %v",
tc.ID, err)
}
case expectedReject:
if err == nil {
t.Errorf(
"test case %d failed: expected failure, but verification succeeded",
tc.ID)
}
default:
t.Fatalf(
"test case %d failed: unknown expected result: %s",
tc.ID, tc.Expected)
}
}
}
func betterTLSTestData(t *testing.T) (betterTLS, *CertPool) {
const (
bettertlsModule = "github.com/Netflix/bettertls"
bettertlsVersion = "v0.0.0-20250909192348-e1e99e353074"
)
bettertlsDir := cryptotest.FetchModule(t, bettertlsModule, bettertlsVersion)
tempDir := t.TempDir()
testsJSONPath := filepath.Join(tempDir, "tests.json")
cmd := testenv.Command(t, testenv.GoToolPath(t),
"run", "./test-suites/cmd/bettertls",
"export-tests",
"--out", testsJSONPath)
cmd.Dir = bettertlsDir
t.Log("running bettertls export-tests command")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to run bettertls export-tests: %v\n%s", err, out)
}
jsonData, err := os.ReadFile(testsJSONPath)
if err != nil {
t.Fatalf("failed to read exported tests.json: %v", err)
}
t.Logf("successfully loaded tests.json at %s", testsJSONPath)
var data betterTLS
if err := json.Unmarshal(jsonData, &data); err != nil {
t.Fatalf("failed to unmarshal JSON data: %v", err)
}
t.Logf("testing betterTLS revision: %s", data.Revision)
t.Logf("number of test suites: %d", len(data.Suites))
rootDER, err := data.RootCert()
if err != nil {
t.Fatalf("failed to decode trust root: %v", err)
}
rootCert, err := ParseCertificate(rootDER)
if err != nil {
t.Fatalf("failed to parse trust root certificate: %v", err)
}
roots := NewCertPool()
roots.AddCert(rootCert)
return data, roots
}
type betterTLS struct {
Revision string `json:"betterTlsRevision"`
Root string `json:"trustRoot"`
Suites map[string]betterTLSSuite `json:"suites"`
}
func (b *betterTLS) RootCert() ([]byte, error) {
return base64.StdEncoding.DecodeString(b.Root)
}
type betterTLSSuite struct {
TestCases []betterTLSTest `json:"testCases"`
}
type betterTLSTest struct {
ID uint32 `json:"id"`
Certificates []string `json:"certificates"`
Hostname string `json:"hostname"`
Expected expectedResult `json:"expected"`
}
func (test *betterTLSTest) Certs() ([][]byte, error) {
certs := make([][]byte, len(test.Certificates))
for i, cert := range test.Certificates {
decoded, err := base64.StdEncoding.DecodeString(cert)
if err != nil {
return nil, err
}
certs[i] = decoded
}
return certs, nil
}
type expectedResult string
const (
expectedAccept expectedResult = "ACCEPT"
expectedReject expectedResult = "REJECT"
)