diff --git a/config/config.go b/config/config.go index 303107f37..dcb5b46c0 100644 --- a/config/config.go +++ b/config/config.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io/ioutil" + "net" "regexp" "strconv" "strings" @@ -19,6 +20,7 @@ import ( "github.com/cloudflare/cfssl/helpers" "github.com/cloudflare/cfssl/log" ocspConfig "github.com/cloudflare/cfssl/ocsp/config" + // empty import of zlint/v3 required to have lints registered. _ "github.com/zmap/zlint/v3" "github.com/zmap/zlint/v3/lint" @@ -67,9 +69,18 @@ type AuthRemote struct { // CAConstraint would verify against (and override) the CA // extensions in the given CSR. type CAConstraint struct { - IsCA bool `json:"is_ca"` - MaxPathLen int `json:"max_path_len"` - MaxPathLenZero bool `json:"max_path_len_zero"` + IsCA bool `json:"is_ca"` + MaxPathLen int `json:"max_path_len"` + MaxPathLenZero bool `json:"max_path_len_zero"` + PermittedDNSDomainsCritical bool `json:"permitted_dns_domains_critical"` + PermittedDNSDomains []string `json:"permitted_dns_domains"` + ExcludedDNSDomains []string `json:"excluded_dns_domains"` + PermittedIPRanges []*net.IPNet `json:"permitted_ip_ranges"` + ExcludedIPRanges []*net.IPNet `json:"excluded_ip_ranges"` + PermittedEmailAddresses []string `json:"permitted_email_addresses"` + ExcludedEmailAddresses []string `json:"excluded_email_addresses"` + PermittedURIDomains []string `json:"permitted_uri_domains"` + ExcludedURIDomains []string `json:"excluded_uri_domains"` } // A SigningProfile stores information that the CA needs to store diff --git a/config/config_test.go b/config/config_test.go index 1ba994504..c8367624f 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -255,6 +255,49 @@ var validLocalConfigsWithCAConstraint = []string{ } } }`, + `{ + "signing": { + "default": { + "usages": ["digital signature", "email protection"], + "ca_constraint": { + "is_ca": true, + "max_path_len_zero": true, + "permitted_dns_domains_critical": true, + "permitted_dns_domains": [ + ".example.com" + ], + "excluded_dns_domains": [ + ".example.com" + ], + "permitted_ip_ranges": [ + { + "IP": "192.168.0.0", + "Mask": "//8AAA==" + } + ], + "excluded_ip_ranges": [ + { + "IP": "172.16.0.0", + "Mask": "//AAAA==" + } + ], + "permitted_email_addresses": [ + "foo@bar.com" + ], + "excluded_email_addresses": [ + "hinz@kunz.com" + ], + "permitted_uri_domains": [ + ".example.com" + ], + "excluded_uri_domains": [ + "host.hurzel.com" + ] + }, + "expiry": "8000h" + } + } + }`, } var copyExtensionWantedlLocalConfig = ` @@ -443,6 +486,7 @@ func TestLoadFile(t *testing.T) { "testdata/valid_config.json", "testdata/valid_config_auth.json", "testdata/valid_config_no_default.json", + "testdata/valid_config_ca_constraints.json", "testdata/valid_config_auth_no_default.json", } diff --git a/config/testdata/valid_config_ca_constraints.json b/config/testdata/valid_config_ca_constraints.json new file mode 100644 index 000000000..e6d084d78 --- /dev/null +++ b/config/testdata/valid_config_ca_constraints.json @@ -0,0 +1,46 @@ +{ + "signing": { + "default": { + "usages": [ + "digital signature", + "email protection" + ], + "ca_constraint": { + "is_ca": true, + "max_path_len_zero": true, + "permitted_dns_domains_critical": true, + "permitted_dns_domains": [ + ".example.com" + ], + "excluded_dns_domains": [ + ".example.com" + ], + "permitted_ip_ranges": [ + { + "IP": "192.168.0.0", + "Mask": "//8AAA==" + } + ], + "excluded_ip_ranges": [ + { + "IP": "172.16.0.0", + "Mask": "//AAAA==" + } + ], + "permitted_email_addresses": [ + "foo@bar.com" + ], + "excluded_email_addresses": [ + "hinz@kunz.com" + ], + "permitted_uri_domains": [ + ".example.com" + ], + "excluded_uri_domains": [ + "host.hurzel.com" + ] + }, + "expiry": "8000h" + } + } +} diff --git a/doc/cmd/cfssl.txt b/doc/cmd/cfssl.txt index 581ffb43b..94be2cb53 100644 --- a/doc/cmd/cfssl.txt +++ b/doc/cmd/cfssl.txt @@ -130,6 +130,17 @@ blank. Notice the extra "max_path_len_zero" field: Without it, the intermediate CA certificate will have no pathlen constraint. + + RFC 5280 4.2.1.10 Name Constraints + + permitted_dns_domains_critical + + permitted_dns_domains + + excluded_dns_domains + + permitted_ip_ranges + + excluded_ip_ranges + + permitted_email_addresses + + excluded_email_addresses + + permitted_uri_domains + + excluded_uri_domains + + ocsp_no_check: this should be true if the id-pkix-ocsp-nocheck extension should be used (RFC 2560 4.2.2.2.1). diff --git a/signer/local/local_test.go b/signer/local/local_test.go index d8e4dd606..756eea4d0 100644 --- a/signer/local/local_test.go +++ b/signer/local/local_test.go @@ -31,7 +31,7 @@ import ( "github.com/cloudflare/cfssl/helpers" "github.com/cloudflare/cfssl/log" "github.com/cloudflare/cfssl/signer" - "github.com/google/certificate-transparency-go" + ct "github.com/google/certificate-transparency-go" "github.com/zmap/zlint/v3/lint" ) @@ -963,6 +963,119 @@ func TestCASignPathlen(t *testing.T) { } } +func TestCAConstraints(t *testing.T) { + var caCerts = []string{testCaFile, testECDSACaFile} + var caKeys = []string{testCaKeyFile, testECDSACaKeyFile} + var interCSRs = []string{ecdsaInterCSR, rsaInterCSR} + var interKeys = []string{ecdsaInterKey, rsaInterKey} + var CAPolicy = &config.Signing{ + Default: &config.SigningProfile{ + Usage: []string{"cert sign", "crl sign"}, + ExpiryString: "1h", + Expiry: 1 * time.Hour, + CAConstraint: config.CAConstraint{ + IsCA: true, + MaxPathLenZero: true, + PermittedDNSDomainsCritical: true, + PermittedDNSDomains: []string{".sub.cloudflare-inter.com"}, + ExcludedDNSDomains: []string{".forbidden.cloudflare-inter.com"}, + PermittedIPRanges: []*net.IPNet{{ + IP: net.IP{0xc0, 0xa8, 0x0, 0x0}, + Mask: net.IPMask{0xff, 0xff, 0x0, 0x0}}}, + ExcludedIPRanges: []*net.IPNet{{ + IP: net.IP{172, 16, 0x0, 0x0}, + Mask: net.IPMask{0xff, 0xf0, 0x0, 0x0}}}, + PermittedEmailAddresses: []string{"root@example.com"}, + ExcludedEmailAddresses: []string{".illegal.com"}, + PermittedURIDomains: []string{".example.com"}, + ExcludedURIDomains: []string{"host.illegal.com"}}, + }, + } + var hostname = "cloudflare-inter.com" + // Each RSA or ECDSA root CA issues two intermediate CAs (one ECDSA and one RSA). + // For each intermediate CA, use it to issue additional RSA and ECDSA intermediate CSRs. + for i, caFile := range caCerts { + caKeyFile := caKeys[i] + s := newCustomSigner(t, caFile, caKeyFile) + s.policy = CAPolicy + for j, csr := range interCSRs { + csrBytes, _ := ioutil.ReadFile(csr) + certBytes, err := s.Sign(signer.SignRequest{Hosts: signer.SplitHosts(hostname), Request: string(csrBytes)}) + if err != nil { + t.Fatal(err) + } + interCert, err := helpers.ParseCertificatePEM(certBytes) + if err != nil { + t.Fatal(err) + } + keyBytes, _ := ioutil.ReadFile(interKeys[j]) + interKey, _ := helpers.ParsePrivateKeyPEM(keyBytes) + interSigner := &Signer{ + ca: interCert, + priv: interKey, + policy: CAPolicy, + sigAlgo: signer.DefaultSigAlgo(interKey), + } + for _, anotherCSR := range interCSRs { + anotherCSRBytes, _ := ioutil.ReadFile(anotherCSR) + bytes, err := interSigner.Sign( + signer.SignRequest{ + Hosts: signer.SplitHosts(hostname), + Request: string(anotherCSRBytes), + }) + if err != nil { + t.Fatal(err) + } + cert, err := helpers.ParseCertificatePEM(bytes) + if err != nil { + t.Fatal(err) + } + if cert.SignatureAlgorithm != interSigner.SigAlgo() { + t.Fatal("Cert Signature Algorithm does not match the issuer.") + } + if cert.MaxPathLen != 0 { + t.Fatal("CA Cert Max Path is not zero.") + } + if cert.MaxPathLenZero != true { + t.Fatal("CA Cert Max Path is not zero.") + } + if cert.PermittedDNSDomainsCritical != true { + t.Fatal("CA Cert Permitted DNS Domains Critical is not true..") + } + if !reflect.DeepEqual(cert.PermittedDNSDomains, []string{".sub.cloudflare-inter.com"}) { + t.Fatal("CA Cert Permitted DNS Domains is not equal.") + } + if !reflect.DeepEqual(cert.ExcludedDNSDomains, []string{".forbidden.cloudflare-inter.com"}) { + t.Fatal("CA Cert Excluded DNS Domains is not equal.") + } + if !reflect.DeepEqual(cert.PermittedIPRanges, []*net.IPNet{{ + IP: net.IP{0xc0, 0xa8, 0x0, 0x0}, + Mask: net.IPMask{0xff, 0xff, 0x0, 0x0}}}) { + t.Fatal("CA Cert Permitted IP Ranges is not equal.") + } + if !reflect.DeepEqual(cert.ExcludedIPRanges, []*net.IPNet{{ + IP: net.IP{172, 16, 0x0, 0x0}, + Mask: net.IPMask{0xff, 0xf0, 0x0, 0x0}}}) { + t.Fatal("CA Cert Excluded IP Ranges is not equal.") + } + if !reflect.DeepEqual(cert.PermittedEmailAddresses, []string{"root@example.com"}) { + t.Fatal("CA Cert Permitted Email Addresses is not equal.") + } + if !reflect.DeepEqual(cert.ExcludedEmailAddresses, []string{".illegal.com"}) { + t.Fatal("CA Cert Excluded Email Addresses is not equal.") + } + if !reflect.DeepEqual(cert.PermittedURIDomains, []string{".example.com"}) { + t.Fatal("CA Cert Permitted URI Domains is not equal.") + } + if !reflect.DeepEqual(cert.ExcludedURIDomains, []string{"host.illegal.com"}) { + t.Fatal("CA Cert Excluded URI Domains is not equal.") + } + } + } + } + +} + func TestNoWhitelistSign(t *testing.T) { csrPEM, err := ioutil.ReadFile(fullSubjectCSR) if err != nil { diff --git a/signer/signer.go b/signer/signer.go index ea650bd6d..dbfc6c4d2 100644 --- a/signer/signer.go +++ b/signer/signer.go @@ -358,6 +358,15 @@ func FillTemplate(template *x509.Certificate, defaultProfile, profile *config.Si template.DNSNames = nil template.EmailAddresses = nil template.URIs = nil + template.PermittedDNSDomainsCritical = profile.CAConstraint.PermittedDNSDomainsCritical + template.PermittedDNSDomains = profile.CAConstraint.PermittedDNSDomains + template.ExcludedDNSDomains = profile.CAConstraint.ExcludedDNSDomains + template.PermittedIPRanges = profile.CAConstraint.PermittedIPRanges + template.ExcludedIPRanges = profile.CAConstraint.ExcludedIPRanges + template.PermittedEmailAddresses = profile.CAConstraint.PermittedEmailAddresses + template.ExcludedEmailAddresses = profile.CAConstraint.ExcludedEmailAddresses + template.PermittedURIDomains = profile.CAConstraint.PermittedURIDomains + template.ExcludedURIDomains = profile.CAConstraint.ExcludedURIDomains } template.SubjectKeyId = ski