Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

va: Support IP address identifiers #8020

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4aa8275
va: Use Identifier in PerformValidationRequest & support IP address i…
jprenken Feb 21, 2025
aee999e
Merge branch 'main' of github.com:letsencrypt/boulder into va-identif…
jprenken Feb 21, 2025
a96107f
Fix tests: ASN.1 tag for IPs, err var name, bare IP redir
jprenken Feb 21, 2025
9bd2763
Update TODOs, clarify var names
jprenken Feb 21, 2025
d8e90bc
Fix lint, bring bad port test closer to original
jprenken Feb 21, 2025
b8aed44
Fix initialURL composition; update test strings to expect explicit po…
jprenken Feb 21, 2025
76d40b7
Fix redirect loop test for the new era of explicit port numbers
jprenken Feb 21, 2025
8a96db7
Revert explicit port number; hardcode port in processHTTPValidation i…
jprenken Feb 22, 2025
1423162
Remove superfluous Port from TestFetchHTTP struct
jprenken Feb 22, 2025
c7ae932
Fix TestHTTP failure
jprenken Feb 22, 2025
e330358
Move tlsConfig generation inside getChallengeCert; fix SNI hostname f…
jprenken Feb 22, 2025
84848b6
Tiny name & comment updates
jprenken Feb 22, 2025
f76c8ac
Regenerate PBs with comment
jprenken Feb 22, 2025
9df1046
Add more test cases
jprenken Feb 22, 2025
0e52b95
Add test cases & correctly handle bare IPv6 in redirects
jprenken Feb 23, 2025
ad6b6b2
Fix RFC 8738 Sec. 3 compliance; add IPv6 httptest ability; fix bare I…
jprenken Feb 23, 2025
e0692b8
Add backstop test for RFC 8738, Section 6
jprenken Feb 23, 2025
0197920
Future-proof for IPv7
jprenken Feb 23, 2025
b26365f
Add test cases; remove dnsi test function
jprenken Feb 23, 2025
1131332
Introduce identifier.FromProtoWithDefault
jprenken Feb 24, 2025
0d43377
Combine struct composition
jprenken Feb 24, 2025
8f57ef6
Merge branch 'main' of github.com:letsencrypt/boulder into va-identif…
jprenken Feb 25, 2025
4d19c25
Address feedback
jprenken Feb 25, 2025
d05232b
Address feedback: refactor checkExpectedSAN
jprenken Feb 25, 2025
67b0090
Address feedback: refactor host/port parsing in extractRequestTarget
jprenken Feb 25, 2025
7c57e1f
Merge branch 'main' of github.com:letsencrypt/boulder into va-identif…
jprenken Feb 25, 2025
dffcc99
Address feedback: clarify FromProtoOrName
jprenken Feb 25, 2025
6e38276
Change FromProtoOrName to FromProtoWithDefault, improve call sites
jprenken Feb 25, 2025
d281836
Address feedback
jprenken Feb 25, 2025
1699a1c
Address feedback: clean up extractRequestTarget changes & improve com…
jprenken Feb 25, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions bdns/mocks.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ func (mock *MockClient) LookupTXT(_ context.Context, hostname string) ([]string,
// expected token + test account jwk thumbprint
return []string{"LPsIwTo7o8BoG0-vjCyGQGBWSVIPxI-i_X336eUOQZo"}, ResolverAddrs{"MockClient"}, nil
}
if hostname == "_acme-challenge.good-dns02.com" {
// base64(sha256("LoqXcYV8q5ONbJQxbmR7SCTNo3tiAXDfowyjxAjEuX0"
// + "." + "9jg46WB3rR_AHD-EBXdN7cBkH1WOu0tA3M9fm21mqTI"))
// expected token + test account jwk thumbprint
return []string{"LPsIwTo7o8BoG0-vjCyGQGBWSVIPxI-i_X336eUOQZo"}, ResolverAddrs{"MockClient"}, nil
}
if hostname == "_acme-challenge.wrong-dns01.com" {
return []string{"a"}, ResolverAddrs{"MockClient"}, nil
}
Expand Down
2 changes: 2 additions & 0 deletions core/objects.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,8 @@ type ValidationRecord struct {
URL string `json:"url,omitempty"`

// Shared
//
// TODO(#7311): Replace DnsName with Identifier.
DnsName string `json:"hostname,omitempty"`
Port string `json:"port,omitempty"`
AddressesResolved []net.IP `json:"addressesResolved,omitempty"`
Expand Down
7 changes: 7 additions & 0 deletions identifier/identifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,13 @@ func (i ACMEIdentifier) AsProto() *corepb.Identifier {
}
}

func FromProto(ident *corepb.Identifier) ACMEIdentifier {
return ACMEIdentifier{
Type: IdentifierType(ident.Type),
Value: ident.Value,
}
}

// NewDNS is a convenience function for creating an ACMEIdentifier with Type
// "dns" for a given domain name.
func NewDNS(domain string) ACMEIdentifier {
Expand Down
2 changes: 1 addition & 1 deletion va/caa.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ func (va *ValidationAuthorityImpl) IsCAAValid(ctx context.Context, req *vapb.IsC
// TODO(#7061) Plumb req.Authz.Id as "AuthzID:" through from the RA to
// correlate which authz triggered this request.
Requester: req.AccountURIID,
Identifier: req.Domain,
Identifier: identifier.NewDNS(req.Domain),
}

challType := core.AcmeChallenge(req.ValidationMethod)
Expand Down
2 changes: 1 addition & 1 deletion va/dns.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ func availableAddresses(allAddrs []net.IP) (v4 []net.IP, v6 []net.IP) {
func (va *ValidationAuthorityImpl) validateDNS01(ctx context.Context, ident identifier.ACMEIdentifier, keyAuthorization string) ([]core.ValidationRecord, error) {
if ident.Type != identifier.TypeDNS {
va.log.Infof("Identifier type for DNS challenge was not DNS: %s", ident)
return nil, berrors.MalformedError("Identifier type for DNS was not itself DNS")
return nil, berrors.MalformedError("Identifier type for DNS challenge was not DNS")
}

// Compute the digest of the key authorization file
Expand Down
2 changes: 1 addition & 1 deletion va/dns_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestDNSValidationEmpty(t *testing.T) {

// This test calls PerformValidation directly, because that is where the
// metrics checked below are incremented.
req := createValidationRequest("empty-txts.com", core.ChallengeTypeDNS01)
req := createValidationRequest(identifier.NewDNS("empty-txts.com"), core.ChallengeTypeDNS01)
res, _ := va.PerformValidation(context.Background(), req)
test.AssertEquals(t, res.Problem.ProblemType, "unauthorized")
test.AssertEquals(t, res.Problem.Detail, "No TXT record found at _acme-challenge.empty-txts.com")
Expand Down
87 changes: 52 additions & 35 deletions va/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"
"net"
"net/http"
"net/netip"
"net/url"
"strconv"
"strings"
Expand Down Expand Up @@ -159,7 +160,7 @@ func httpTransport(df dialerFunc) *http.Transport {
// httpValidationTarget bundles all of the information needed to make an HTTP-01
// validation request against a target.
type httpValidationTarget struct {
// the hostname being validated
// the host being validated
host string
// the port for the validation request
port int
Expand Down Expand Up @@ -201,20 +202,32 @@ func (vt *httpValidationTarget) nextIP() error {
// port, and path. This involves querying DNS for the IP addresses for the host.
// An error is returned if there are no usable IP addresses or if the DNS
// lookups fail.
//
// TODO(#8020): This needs testing with IP address identifiers.
func (va *ValidationAuthorityImpl) newHTTPValidationTarget(
ctx context.Context,
host string,
ident identifier.ACMEIdentifier,
port int,
path string,
query string) (*httpValidationTarget, error) {
// Resolve IP addresses for the hostname
addrs, resolvers, err := va.getAddrs(ctx, host)
if err != nil {
return nil, err
var addrs []net.IP
var resolvers bdns.ResolverAddrs
switch ident.Type {
case identifier.TypeDNS:
// Resolve IP addresses for the identifier
dnsAddrs, dnsResolvers, err := va.getAddrs(ctx, ident.Value)
if err != nil {
return nil, err
}
addrs, resolvers = dnsAddrs, dnsResolvers
case identifier.TypeIP:
addrs = []net.IP{net.ParseIP(ident.Value)}
default:
return nil, fmt.Errorf("Unknown identifier type: %s", ident.Type)
}

target := &httpValidationTarget{
host: host,
host: ident.Value,
port: port,
path: path,
query: query,
Expand All @@ -230,7 +243,7 @@ func (va *ValidationAuthorityImpl) newHTTPValidationTarget(
if !hasV6Addrs && !hasV4Addrs {
// If there are no v6 addrs and no v4addrs there was a bug with getAddrs or
// availableAddresses and we need to return an error.
return nil, fmt.Errorf("host %q has no IPv4 or IPv6 addresses", host)
return nil, fmt.Errorf("host %q has no IPv4 or IPv6 addresses", ident.Value)
} else if !hasV6Addrs && hasV4Addrs {
// If there are no v6 addrs and there are v4 addrs then use the first v4
// address. There's no fallback address.
Expand All @@ -250,23 +263,21 @@ func (va *ValidationAuthorityImpl) newHTTPValidationTarget(
return target, nil
}

// extractRequestTarget extracts the hostname and port specified in the provided
// extractRequestTarget extracts the host and port specified in the provided
// HTTP redirect request. If the request's URL's protocol schema is not HTTP or
// HTTPS an error is returned. If an explicit port is specified in the request's
// URL and it isn't the VA's HTTP or HTTPS port, an error is returned. If the
// request's URL's Host is a bare IPv4 or IPv6 address and not a domain name an
// error is returned.
func (va *ValidationAuthorityImpl) extractRequestTarget(req *http.Request) (string, int, error) {
// URL and it isn't the VA's HTTP or HTTPS port, an error is returned.
func (va *ValidationAuthorityImpl) extractRequestTarget(req *http.Request) (identifier.ACMEIdentifier, int, error) {
// A nil request is certainly not a valid redirect and has no port to extract.
if req == nil {
return "", 0, fmt.Errorf("redirect HTTP request was nil")
return identifier.ACMEIdentifier{}, 0, fmt.Errorf("redirect HTTP request was nil")
}

reqScheme := req.URL.Scheme

// The redirect request must use HTTP or HTTPs protocol schemes regardless of the port..
if reqScheme != "http" && reqScheme != "https" {
return "", 0, berrors.ConnectionFailureError(
return identifier.ACMEIdentifier{}, 0, berrors.ConnectionFailureError(
"Invalid protocol scheme in redirect target. "+
`Only "http" and "https" protocol schemes are supported, not %q`, reqScheme)
}
Expand All @@ -280,12 +291,12 @@ func (va *ValidationAuthorityImpl) extractRequestTarget(req *http.Request) (stri
reqHost = h
reqPort, err = strconv.Atoi(p)
if err != nil {
return "", 0, err
return identifier.ACMEIdentifier{}, 0, err
}

// The explicit port must match the VA's configured HTTP or HTTPS port.
if reqPort != va.httpPort && reqPort != va.httpsPort {
return "", 0, berrors.ConnectionFailureError(
return identifier.ACMEIdentifier{}, 0, berrors.ConnectionFailureError(
"Invalid port in redirect target. Only ports %d and %d are supported, not %d",
va.httpPort, va.httpsPort, reqPort)
}
Expand All @@ -296,17 +307,11 @@ func (va *ValidationAuthorityImpl) extractRequestTarget(req *http.Request) (stri
} else {
// This shouldn't happen but defensively return an internal server error in
// case it does.
return "", 0, fmt.Errorf("unable to determine redirect HTTP request port")
return identifier.ACMEIdentifier{}, 0, fmt.Errorf("unable to determine redirect HTTP request port")
}

if reqHost == "" {
return "", 0, berrors.ConnectionFailureError("Invalid empty hostname in redirect target")
}

// Check that the request host isn't a bare IP address. We only follow
// redirects to hostnames.
if net.ParseIP(reqHost) != nil {
return "", 0, berrors.ConnectionFailureError("Invalid host in redirect target %q. Only domain names are supported, not IP addresses", reqHost)
return identifier.ACMEIdentifier{}, 0, berrors.ConnectionFailureError("Invalid empty host in redirect target")
}

// Often folks will misconfigure their webserver to send an HTTP redirect
Expand All @@ -319,17 +324,28 @@ func (va *ValidationAuthorityImpl) extractRequestTarget(req *http.Request) (stri
// This happens frequently enough we want to return a distinct error message
// for this case by detecting the reqHost ending in ".well-known".
if strings.HasSuffix(reqHost, ".well-known") {
return "", 0, berrors.ConnectionFailureError(
return identifier.ACMEIdentifier{}, 0, berrors.ConnectionFailureError(
"Invalid host in redirect target %q. Check webserver config for missing '/' in redirect target.",
reqHost,
)
}

// We use net.ParseIP to check whether the host is an IP address; otherwise,
// netip.ParseAddr would happily ingest a hostname, returning a garbage IP
// and no error.
if net.ParseIP(reqHost) != nil {
reqIP, err := netip.ParseAddr(reqHost)
if err != nil {
return identifier.ACMEIdentifier{}, 0, berrors.ConnectionFailureError("Invalid IP address in redirect target %q", reqHost)
}
return identifier.NewIP(reqIP), reqPort, nil
}

if _, err := iana.ExtractSuffix(reqHost); err != nil {
return "", 0, berrors.ConnectionFailureError("Invalid hostname in redirect target, must end in IANA registered TLD")
return identifier.ACMEIdentifier{}, 0, berrors.ConnectionFailureError("Invalid host in redirect target, must end in IANA registered TLD")
}

return reqHost, reqPort, nil
return identifier.NewDNS(reqHost), reqPort, nil
}

// setupHTTPValidation sets up a preresolvedDialer and a validation record for
Expand Down Expand Up @@ -403,18 +419,18 @@ func fallbackErr(err error) bool {
// a non-nil error and potentially some ValidationRecords are returned.
func (va *ValidationAuthorityImpl) processHTTPValidation(
ctx context.Context,
host string,
ident identifier.ACMEIdentifier,
path string) ([]byte, []core.ValidationRecord, error) {
// Create a target for the host, port and path with no query parameters
target, err := va.newHTTPValidationTarget(ctx, host, va.httpPort, path, "")
target, err := va.newHTTPValidationTarget(ctx, ident, va.httpPort, path, "")
if err != nil {
return nil, nil, err
}

// Create an initial GET Request
initialURL := url.URL{
Scheme: "http",
Host: host,
Host: ident.Value,
Path: path,
}
initialReq, err := http.NewRequest("GET", initialURL.String(), nil)
Expand Down Expand Up @@ -626,14 +642,15 @@ func (va *ValidationAuthorityImpl) processHTTPValidation(
}

func (va *ValidationAuthorityImpl) validateHTTP01(ctx context.Context, ident identifier.ACMEIdentifier, token string, keyAuthorization string) ([]core.ValidationRecord, error) {
if ident.Type != identifier.TypeDNS {
va.log.Infof("Got non-DNS identifier for HTTP validation: %s", ident)
return nil, berrors.MalformedError("Identifier type for HTTP validation was not DNS")
// TODO(#8020): This needs testing.
if ident.Type != identifier.TypeDNS && ident.Type != identifier.TypeIP {
va.log.Info(fmt.Sprintf("Identifier type for HTTP-01 challenge was not DNS or IP: %s", ident))
return nil, berrors.MalformedError("Identifier type for HTTP-01 challenge was not DNS or IP")
}

// Perform the fetch
path := fmt.Sprintf(".well-known/acme-challenge/%s", token)
body, validationRecords, err := va.processHTTPValidation(ctx, ident.Value, "/"+path)
body, validationRecords, err := va.processHTTPValidation(ctx, ident, "/"+path)
if err != nil {
return validationRecords, err
}
Expand Down
Loading
Loading