diff --git a/flake.nix b/flake.nix index 3df6443b7..c8741f42a 100644 --- a/flake.nix +++ b/flake.nix @@ -173,4 +173,4 @@ }); }; } -# nix-direnv cache busting line: sha256-XZtd1uvpeO4djloYPurifkxc6BnhRDtBfIO6lE38Usc= +# nix-direnv cache busting line: sha256-plOIT+B2mVkUr038G4+qZt8BlUrmAH2oaVf99wK7iik= diff --git a/flakehashes.json b/flakehashes.json index 8788aa36f..bd2a2c122 100644 --- a/flakehashes.json +++ b/flakehashes.json @@ -4,7 +4,7 @@ "sri": "sha256-TwIaPmGVHO9ZwdaO/jDStgWGQtKa4smS3er1i5aTNTw=" }, "vendor": { - "goModSum": "sha256-EDmslq4EjYvjO5S6HwsddmmtxMqB3cVijhYBbcyBGPQ=", - "sri": "sha256-XZtd1uvpeO4djloYPurifkxc6BnhRDtBfIO6lE38Usc=" + "goModSum": "sha256-nSe6X/h4KPoFvqpszhGr6u9BO7BQgL6yjpdRAGEABzE=", + "sri": "sha256-plOIT+B2mVkUr038G4+qZt8BlUrmAH2oaVf99wK7iik=" } } diff --git a/go.mod b/go.mod index d5c276a2d..b0a2a06c8 100644 --- a/go.mod +++ b/go.mod @@ -103,7 +103,7 @@ require ( github.com/tailscale/depaware v0.0.0-20251001183927-9c2ad255ef3f github.com/tailscale/gliderssh v0.3.4-0.20260716005906-1a0f895faf28 github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 - github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 + github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd github.com/tailscale/mkctr v0.0.0-20260107121656-ea857e3e500b github.com/tailscale/netlink v1.1.1-0.20240822203006-4d49adab4de7 diff --git a/go.sum b/go.sum index 821ba69d8..d1e118c66 100644 --- a/go.sum +++ b/go.sum @@ -1195,8 +1195,8 @@ github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8 github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55/go.mod h1:4k4QO+dQ3R5FofL+SanAUZe+/QfeK0+OIuwDIRu2vSg= github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41 h1:/V2rCMMWcsjYaYO2MeovLw+ClP63OtXgCF2Y1eb8+Ns= github.com/tailscale/goexpect v0.0.0-20210902213824-6e8c725cea41/go.mod h1:/roCdA6gg6lQyw/Oz6gIIGu3ggJKYhF+WC/AQReE5XQ= -github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869 h1:SRL6irQkKGQKKLzvQP/ke/2ZuB7Py5+XuqtOgSj+iMM= -github.com/tailscale/golang-x-crypto v0.0.0-20250404221719-a5573b049869/go.mod h1:ikbF+YT089eInTp9f2vmvy4+ZVnW5hzX1q2WknxSprQ= +github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed h1:uyvHhX1FQada0vVk8CSHa4tJT96EEAkTypaYz8Tq5Nc= +github.com/tailscale/golang-x-crypto v0.0.0-20260720153645-2ba0bf7866ed/go.mod h1:NC3xRCu4UR+m4n6ix8b6oLLbHa820Y0StbOQEdWTDo0= github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= github.com/tailscale/mkctr v0.0.0-20260107121656-ea857e3e500b h1:QKqCnmp0qHWUHySySKjpuhZANzRn7XrTVZWUuUgJ3lQ= diff --git a/shell.nix b/shell.nix index 725a4ef91..a24f9f267 100644 --- a/shell.nix +++ b/shell.nix @@ -16,4 +16,4 @@ ) { src = ./.; }).shellNix -# nix-direnv cache busting line: sha256-XZtd1uvpeO4djloYPurifkxc6BnhRDtBfIO6lE38Usc= +# nix-direnv cache busting line: sha256-plOIT+B2mVkUr038G4+qZt8BlUrmAH2oaVf99wK7iik= diff --git a/tempfork/acme/acme.go b/tempfork/acme/acme.go index bbddb9551..96a717d3e 100644 --- a/tempfork/acme/acme.go +++ b/tempfork/acme/acme.go @@ -31,12 +31,11 @@ "crypto/x509/pkix" "encoding/asn1" "encoding/base64" - "encoding/hex" "encoding/json" - "encoding/pem" "errors" "fmt" "math/big" + "net" "net/http" "strings" "sync" @@ -187,10 +186,11 @@ func (c *Client) Discover(ctx context.Context) (Directory, error) { KeyChange string `json:"keyChange"` RenewalInfo string `json:"renewalInfo"` Meta struct { - Terms string `json:"termsOfService"` - Website string `json:"website"` - CAA []string `json:"caaIdentities"` - ExternalAcct bool `json:"externalAccountRequired"` + Terms string `json:"termsOfService"` + Website string `json:"website"` + CAA []string `json:"caaIdentities"` + ExternalAcct bool `json:"externalAccountRequired"` + Profiles map[string]string `json:"profiles"` } } if err := json.NewDecoder(res.Body).Decode(&v); err != nil { @@ -211,6 +211,7 @@ func (c *Client) Discover(ctx context.Context) (Directory, error) { Website: v.Meta.Website, CAA: v.Meta.CAA, ExternalAccountRequired: v.Meta.ExternalAcct, + Profiles: v.Meta.Profiles, } return *c.dir, nil } @@ -397,6 +398,10 @@ func (c *Client) authorize(ctx context.Context, typ, val string) (*Authorization if _, err := c.Discover(ctx); err != nil { return nil, err } + if c.dir.AuthzURL == "" { + // Pre-Authorization is unsupported + return nil, errPreAuthorizationNotSupported + } type authzID struct { Type string `json:"type"` @@ -511,7 +516,7 @@ func (c *Client) WaitAuthorization(ctx context.Context, url string) (*Authorizat // while waiting for a final authorization status. d := retryAfter(res.Header.Get("Retry-After")) if d == 0 { - // Given that the fastest challenges TLS-SNI and HTTP-01 + // Given that the fastest challenges TLS-ALPN and HTTP-01 // require a CA to make at least 1 network round trip // and most likely persist a challenge state, // this default delay seems reasonable. @@ -612,50 +617,28 @@ func (c *Client) HTTP01ChallengePath(token string) string { } // TLSSNI01ChallengeCert creates a certificate for TLS-SNI-01 challenge response. +// Always returns an error. // -// Deprecated: This challenge type is unused in both draft-02 and RFC versions of the ACME spec. -func (c *Client) TLSSNI01ChallengeCert(token string, opt ...CertOption) (cert tls.Certificate, name string, err error) { - ka, err := keyAuth(c.Key.Public(), token) - if err != nil { - return tls.Certificate{}, "", err - } - b := sha256.Sum256([]byte(ka)) - h := hex.EncodeToString(b[:]) - name = fmt.Sprintf("%s.%s.acme.invalid", h[:32], h[32:]) - cert, err = tlsChallengeCert([]string{name}, opt) - if err != nil { - return tls.Certificate{}, "", err - } - return cert, name, nil +// Deprecated: This challenge type was only present in pre-standardized ACME +// protocol drafts and is insecure for use in shared hosting environments. +func (c *Client) TLSSNI01ChallengeCert(token string, opt ...CertOption) (tls.Certificate, string, error) { + return tls.Certificate{}, "", errPreRFC } // TLSSNI02ChallengeCert creates a certificate for TLS-SNI-02 challenge response. +// Always returns an error. // -// Deprecated: This challenge type is unused in both draft-02 and RFC versions of the ACME spec. -func (c *Client) TLSSNI02ChallengeCert(token string, opt ...CertOption) (cert tls.Certificate, name string, err error) { - b := sha256.Sum256([]byte(token)) - h := hex.EncodeToString(b[:]) - sanA := fmt.Sprintf("%s.%s.token.acme.invalid", h[:32], h[32:]) - - ka, err := keyAuth(c.Key.Public(), token) - if err != nil { - return tls.Certificate{}, "", err - } - b = sha256.Sum256([]byte(ka)) - h = hex.EncodeToString(b[:]) - sanB := fmt.Sprintf("%s.%s.ka.acme.invalid", h[:32], h[32:]) - - cert, err = tlsChallengeCert([]string{sanA, sanB}, opt) - if err != nil { - return tls.Certificate{}, "", err - } - return cert, sanA, nil +// Deprecated: This challenge type was only present in pre-standardized ACME +// protocol drafts and is insecure for use in shared hosting environments. +func (c *Client) TLSSNI02ChallengeCert(token string, opt ...CertOption) (tls.Certificate, string, error) { + return tls.Certificate{}, "", errPreRFC } // TLSALPN01ChallengeCert creates a certificate for TLS-ALPN-01 challenge response. // Servers can present the certificate to validate the challenge and prove control -// over a domain name. For more details on TLS-ALPN-01 see -// https://tools.ietf.org/html/draft-shoemaker-acme-tls-alpn-00#section-3 +// over an identifier (either a DNS name or the textual form of an IPv4 or IPv6 +// address). For more details on TLS-ALPN-01 see +// https://www.rfc-editor.org/rfc/rfc8737 and https://www.rfc-editor.org/rfc/rfc8738 // // The token argument is a Challenge.Token value. // If a WithKey option is provided, its private part signs the returned cert, @@ -663,9 +646,13 @@ func (c *Client) TLSSNI02ChallengeCert(token string, opt ...CertOption) (cert tl // If no WithKey option is provided, a new ECDSA key is generated using P-256 curve. // // The returned certificate is valid for the next 24 hours and must be presented only when -// the server name in the TLS ClientHello matches the domain, and the special acme-tls/1 ALPN protocol +// the server name in the TLS ClientHello matches the identifier, and the special acme-tls/1 ALPN protocol // has been specified. -func (c *Client) TLSALPN01ChallengeCert(token, domain string, opt ...CertOption) (cert tls.Certificate, err error) { +// +// Validation requests for IP address identifiers will use the reverse DNS form in the server name +// in the TLS ClientHello since the SNI extension is not supported for IP addresses. +// See RFC 8738 Section 6 for more information. +func (c *Client) TLSALPN01ChallengeCert(token, identifier string, opt ...CertOption) (cert tls.Certificate, err error) { ka, err := keyAuth(c.Key.Public(), token) if err != nil { return tls.Certificate{}, err @@ -695,7 +682,7 @@ func (c *Client) TLSALPN01ChallengeCert(token, domain string, opt ...CertOption) } tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, acmeExtension) newOpt = append(newOpt, WithTemplate(tmpl)) - return tlsChallengeCert([]string{domain}, newOpt) + return tlsChallengeCert(identifier, newOpt) } // popNonce returns a nonce value previously stored with c.addNonce @@ -749,7 +736,7 @@ func (c *Client) addNonce(h http.Header) { } func (c *Client) fetchNonce(ctx context.Context, url string) (string, error) { - r, err := http.NewRequest("HEAD", url, nil) + r, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) if err != nil { return "", err } @@ -813,11 +800,15 @@ func defaultTLSChallengeCertTemplate() *x509.Certificate { } } -// tlsChallengeCert creates a temporary certificate for TLS-SNI challenges -// with the given SANs and auto-generated public/private key pair. -// The Subject Common Name is set to the first SAN to aid debugging. +// tlsChallengeCert creates a temporary certificate for TLS-ALPN challenges +// for the given identifier, using an auto-generated public/private key pair. +// +// If the provided identifier is a domain name, it will be used as a DNS type SAN and for the +// subject common name. If the provided identifier is an IP address it will be used as an IP type +// SAN. +// // To create a cert with a custom key pair, specify WithKey option. -func tlsChallengeCert(san []string, opt []CertOption) (tls.Certificate, error) { +func tlsChallengeCert(identifier string, opt []CertOption) (tls.Certificate, error) { var key crypto.Signer tmpl := defaultTLSChallengeCertTemplate() for _, o := range opt { @@ -841,9 +832,12 @@ func tlsChallengeCert(san []string, opt []CertOption) (tls.Certificate, error) { return tls.Certificate{}, err } } - tmpl.DNSNames = san - if len(san) > 0 { - tmpl.Subject.CommonName = san[0] + + if ip := net.ParseIP(identifier); ip != nil { + tmpl.IPAddresses = []net.IP{ip} + } else { + tmpl.DNSNames = []string{identifier} + tmpl.Subject.CommonName = identifier } der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, key.Public(), key) @@ -856,11 +850,5 @@ func tlsChallengeCert(san []string, opt []CertOption) (tls.Certificate, error) { }, nil } -// encodePEM returns b encoded as PEM with block of type typ. -func encodePEM(typ string, b []byte) []byte { - pb := &pem.Block{Type: typ, Bytes: b} - return pem.EncodeToMemory(pb) -} - // timeNow is time.Now, except in tests which can mess with it. var timeNow = time.Now diff --git a/tempfork/acme/acme_test.go b/tempfork/acme/acme_test.go index f0c45aea9..fa91ca19a 100644 --- a/tempfork/acme/acme_test.go +++ b/tempfork/acme/acme_test.go @@ -9,13 +9,13 @@ "context" "crypto/rand" "crypto/rsa" - "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" + "errors" "fmt" "io" "math/big" @@ -23,7 +23,6 @@ "net/http/httptest" "net/url" "reflect" - "sort" "strings" "testing" "time" @@ -267,6 +266,57 @@ func TestAuthorizeValid(t *testing.T) { } } +func TestAuthorizeUnsupported(t *testing.T) { + const ( + nonce = "https://example.com/acme/new-nonce" + reg = "https://example.com/acme/new-acct" + order = "https://example.com/acme/new-order" + revoke = "https://example.com/acme/revoke-cert" + keychange = "https://example.com/acme/key-change" + metaTerms = "https://example.com/acme/terms/2017-5-30" + metaWebsite = "https://www.example.com/" + metaCAA = "example.com" + ) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Replay-Nonce", "nonce") + if r.Method == http.MethodHead { + return + } + switch r.URL.Path { + case "/": // Directory + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "newNonce": %q, + "newAccount": %q, + "newOrder": %q, + "revokeCert": %q, + "keyChange": %q, + "meta": { + "termsOfService": %q, + "website": %q, + "caaIdentities": [%q], + "externalAccountRequired": true + } + }`, nonce, reg, order, revoke, keychange, metaTerms, metaWebsite, metaCAA) + w.WriteHeader(http.StatusOK) + case "/acme/new-authz": + w.WriteHeader(http.StatusBadRequest) + } + })) + defer ts.Close() + client := &Client{Key: testKey, DirectoryURL: ts.URL} + dir, err := client.Discover(context.Background()) + if err != nil { + t.Fatal(err) + } + if dir.AuthzURL != "" { + t.Fatalf("expected AuthzURL to be empty, got %q", dir.AuthzURL) + } + if _, err := client.Authorize(context.Background(), "example.com"); !errors.Is(err, errPreAuthorizationNotSupported) { + t.Errorf("expected err to indicate pre-authorization is unsupported, got %+v", err) + } +} + func TestWaitAuthorization(t *testing.T) { t.Run("wait loop", func(t *testing.T) { var count int @@ -755,71 +805,6 @@ func TestLinkHeader(t *testing.T) { } } -func TestTLSSNI01ChallengeCert(t *testing.T) { - const ( - token = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA" - // echo -n | shasum -a 256 - san = "dbbd5eefe7b4d06eb9d1d9f5acb4c7cd.a27d320e4b30332f0b6cb441734ad7b0.acme.invalid" - ) - - tlscert, name, err := newTestClient().TLSSNI01ChallengeCert(token) - if err != nil { - t.Fatal(err) - } - - if n := len(tlscert.Certificate); n != 1 { - t.Fatalf("len(tlscert.Certificate) = %d; want 1", n) - } - cert, err := x509.ParseCertificate(tlscert.Certificate[0]) - if err != nil { - t.Fatal(err) - } - if len(cert.DNSNames) != 1 || cert.DNSNames[0] != san { - t.Fatalf("cert.DNSNames = %v; want %q", cert.DNSNames, san) - } - if cert.DNSNames[0] != name { - t.Errorf("cert.DNSNames[0] != name: %q vs %q", cert.DNSNames[0], name) - } - if cn := cert.Subject.CommonName; cn != san { - t.Errorf("cert.Subject.CommonName = %q; want %q", cn, san) - } -} - -func TestTLSSNI02ChallengeCert(t *testing.T) { - const ( - token = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA" - // echo -n evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA | shasum -a 256 - sanA = "7ea0aaa69214e71e02cebb18bb867736.09b730209baabf60e43d4999979ff139.token.acme.invalid" - // echo -n | shasum -a 256 - sanB = "dbbd5eefe7b4d06eb9d1d9f5acb4c7cd.a27d320e4b30332f0b6cb441734ad7b0.ka.acme.invalid" - ) - - tlscert, name, err := newTestClient().TLSSNI02ChallengeCert(token) - if err != nil { - t.Fatal(err) - } - - if n := len(tlscert.Certificate); n != 1 { - t.Fatalf("len(tlscert.Certificate) = %d; want 1", n) - } - cert, err := x509.ParseCertificate(tlscert.Certificate[0]) - if err != nil { - t.Fatal(err) - } - names := []string{sanA, sanB} - if !reflect.DeepEqual(cert.DNSNames, names) { - t.Fatalf("cert.DNSNames = %v;\nwant %v", cert.DNSNames, names) - } - sort.Strings(cert.DNSNames) - i := sort.SearchStrings(cert.DNSNames, name) - if i >= len(cert.DNSNames) || cert.DNSNames[i] != name { - t.Errorf("%v doesn't have %q", cert.DNSNames, name) - } - if cn := cert.Subject.CommonName; cn != sanA { - t.Errorf("CommonName = %q; want %q", cn, sanA) - } -} - func TestTLSALPN01ChallengeCert(t *testing.T) { const ( token = "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA" @@ -876,6 +861,7 @@ func TestTLSChallengeCertOpt(t *testing.T) { if err != nil { t.Fatal(err) } + domain := "example.com" tmpl := &x509.Certificate{ SerialNumber: big.NewInt(2), Subject: pkix.Name{Organization: []string{"Test"}}, @@ -884,52 +870,43 @@ func TestTLSChallengeCertOpt(t *testing.T) { opts := []CertOption{WithKey(key), WithTemplate(tmpl)} client := newTestClient() - cert1, _, err := client.TLSSNI01ChallengeCert("token", opts...) - if err != nil { - t.Fatal(err) - } - cert2, _, err := client.TLSSNI02ChallengeCert("token", opts...) + cert, err := client.TLSALPN01ChallengeCert("token", domain, opts...) if err != nil { t.Fatal(err) } - for i, tlscert := range []tls.Certificate{cert1, cert2} { - // verify generated cert private key - tlskey, ok := tlscert.PrivateKey.(*rsa.PrivateKey) - if !ok { - t.Errorf("%d: tlscert.PrivateKey is %T; want *rsa.PrivateKey", i, tlscert.PrivateKey) - continue - } - if tlskey.D.Cmp(key.D) != 0 { - t.Errorf("%d: tlskey.D = %v; want %v", i, tlskey.D, key.D) - } - // verify generated cert public key - x509Cert, err := x509.ParseCertificate(tlscert.Certificate[0]) - if err != nil { - t.Errorf("%d: %v", i, err) - continue - } - tlspub, ok := x509Cert.PublicKey.(*rsa.PublicKey) - if !ok { - t.Errorf("%d: x509Cert.PublicKey is %T; want *rsa.PublicKey", i, x509Cert.PublicKey) - continue - } - if tlspub.N.Cmp(key.N) != 0 { - t.Errorf("%d: tlspub.N = %v; want %v", i, tlspub.N, key.N) - } - // verify template option - sn := big.NewInt(2) - if x509Cert.SerialNumber.Cmp(sn) != 0 { - t.Errorf("%d: SerialNumber = %v; want %v", i, x509Cert.SerialNumber, sn) - } - org := []string{"Test"} - if !reflect.DeepEqual(x509Cert.Subject.Organization, org) { - t.Errorf("%d: Subject.Organization = %+v; want %+v", i, x509Cert.Subject.Organization, org) - } - for _, v := range x509Cert.DNSNames { - if !strings.HasSuffix(v, ".acme.invalid") { - t.Errorf("%d: invalid DNSNames element: %q", i, v) - } + // verify generated cert private key + tlskey, ok := cert.PrivateKey.(*rsa.PrivateKey) + if !ok { + t.Fatalf("tlscert.PrivateKey is %T; want *rsa.PrivateKey", cert.PrivateKey) + } + if tlskey.D.Cmp(key.D) != 0 { + t.Errorf("tlskey.D = %v; want %v", tlskey.D, key.D) + } + // verify generated cert public key + x509Cert, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + t.Fatal(err) + } + tlspub, ok := x509Cert.PublicKey.(*rsa.PublicKey) + if !ok { + t.Fatalf("x509Cert.PublicKey is %T; want *rsa.PublicKey", x509Cert.PublicKey) + } + if tlspub.N.Cmp(key.N) != 0 { + t.Errorf("tlspub.N = %v; want %v", tlspub.N, key.N) + } + // verify template option + sn := big.NewInt(2) + if x509Cert.SerialNumber.Cmp(sn) != 0 { + t.Errorf("SerialNumber = %v; want %v", x509Cert.SerialNumber, sn) + } + org := []string{"Test"} + if !reflect.DeepEqual(x509Cert.Subject.Organization, org) { + t.Errorf("Subject.Organization = %+v; want %+v", x509Cert.Subject.Organization, org) + } + for _, v := range x509Cert.DNSNames { + if v != domain { + t.Errorf("invalid DNSNames element: %q", v) } } } diff --git a/tempfork/acme/http.go b/tempfork/acme/http.go index d92ff232f..7d1052acd 100644 --- a/tempfork/acme/http.go +++ b/tempfork/acme/http.go @@ -66,7 +66,7 @@ func (c *Client) retryTimer() *retryTimer { // The n argument is always bounded between 1 and 30. // The returned value is always greater than 0. func defaultBackoff(n int, r *http.Request, res *http.Response) time.Duration { - const max = 10 * time.Second + const maxVal = 10 * time.Second var jitter time.Duration if x, err := rand.Int(rand.Reader, big.NewInt(1000)); err == nil { // Set the minimum to 1ms to avoid a case where @@ -86,10 +86,7 @@ func defaultBackoff(n int, r *http.Request, res *http.Response) time.Duration { n = 30 } d := time.Duration(1< max { - return max - } - return d + return min(d, maxVal) } // retryAfter parses a Retry-After HTTP header value, @@ -131,7 +128,7 @@ func wantStatus(codes ...int) resOkay { func (c *Client) get(ctx context.Context, url string, ok resOkay) (*http.Response, error) { retry := c.retryTimer() for { - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { return nil, err } @@ -231,7 +228,7 @@ func (c *Client) postNoRetry(ctx context.Context, key crypto.Signer, url string, if err != nil { return nil, nil, err } - req, err := http.NewRequest("POST", url, bytes.NewReader(b)) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b)) if err != nil { return nil, nil, err } diff --git a/tempfork/acme/jws.go b/tempfork/acme/jws.go index b38828d85..685027566 100644 --- a/tempfork/acme/jws.go +++ b/tempfork/acme/jws.go @@ -92,7 +92,7 @@ func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid KeyID, nonce, ur if err != nil { return nil, err } - phead := base64.RawURLEncoding.EncodeToString([]byte(phJSON)) + phead := base64.RawURLEncoding.EncodeToString(phJSON) var payload string if val, ok := claimset.(string); ok { payload = val diff --git a/tempfork/acme/rfc8555.go b/tempfork/acme/rfc8555.go index 3eaf935fd..1c161ebe4 100644 --- a/tempfork/acme/rfc8555.go +++ b/tempfork/acme/rfc8555.go @@ -54,6 +54,9 @@ func (c *Client) registerRFC(ctx context.Context, acct *Account, prompt func(tos Contact: acct.Contact, } if c.dir.Terms != "" { + if prompt == nil { + return nil, errors.New("acme: missing Manager.Prompt to accept server's terms of service") + } req.TermsAgreed = prompt(c.dir.Terms) } @@ -207,6 +210,7 @@ func (c *Client) AuthorizeOrder(ctx context.Context, id []AuthzID, opt ...OrderO NotBefore string `json:"notBefore,omitempty"` NotAfter string `json:"notAfter,omitempty"` Replaces string `json:"replaces,omitempty"` + Profile string `json:"profile,omitempty"` }{} for _, v := range id { req.Identifiers = append(req.Identifiers, wireAuthzID{ @@ -228,6 +232,15 @@ func (c *Client) AuthorizeOrder(ctx context.Context, id []AuthzID, opt ...OrderO return nil, fmt.Errorf("failed to parse certificate being replaced: %w", err) } req.Replaces = certRenewalIdentifier(cert) + case orderProfileOpt: + if !dir.Profiles.isSupported() { + return nil, ErrCADoesNotSupportProfiles + } + profileName := string(o) + if !dir.Profiles.Has(profileName) { + return nil, fmt.Errorf("%w %s", ErrProfileNotInSetOfSupportedProfiles, profileName) + } + req.Profile = profileName default: // Package's fault if we let this happen. panic(fmt.Sprintf("unsupported order option type %T", o)) @@ -242,7 +255,7 @@ func (c *Client) AuthorizeOrder(ctx context.Context, id []AuthzID, opt ...OrderO return responseOrder(res) } -// GetOrder retrives an order identified by the given URL. +// GetOrder retrieves an order identified by the given URL. // For orders created with AuthorizeOrder, the url value is Order.URI. // // If a caller needs to poll an order until its status is final, @@ -282,7 +295,7 @@ func (c *Client) WaitOrder(ctx context.Context, url string) (*Order, error) { case err != nil: // Skip and retry. case o.Status == StatusInvalid: - return nil, &OrderError{OrderURL: o.URI, Status: o.Status} + return nil, &OrderError{OrderURL: o.URI, Status: o.Status, Problem: o.Error} case o.Status == StatusReady || o.Status == StatusValid: return o, nil } @@ -379,7 +392,7 @@ func (c *Client) CreateOrderCert(ctx context.Context, url string, csr []byte, bu } // The only acceptable status post finalize and WaitOrder is "valid". if o.Status != StatusValid { - return nil, "", &OrderError{OrderURL: o.URI, Status: o.Status} + return nil, "", &OrderError{OrderURL: o.URI, Status: o.Status, Problem: o.Error} } crt, err := c.fetchCertRFC(ctx, o.CertURL, bundle) return crt, o.CertURL, err diff --git a/tempfork/acme/rfc8555_test.go b/tempfork/acme/rfc8555_test.go index ec51a7a5e..b9ac1e805 100644 --- a/tempfork/acme/rfc8555_test.go +++ b/tempfork/acme/rfc8555_test.go @@ -99,6 +99,61 @@ func TestRFC_Discover(t *testing.T) { if !dir.ExternalAccountRequired { t.Error("dir.Meta.ExternalAccountRequired is false") } + if dir.Profiles != nil { + t.Errorf("dir.Profiles is expected to be nil, got %+v", dir.Profiles) + } +} + +func TestDiscover_WithProfiles(t *testing.T) { + const ( + nonce = "https://example.com/acme/new-nonce" + reg = "https://example.com/acme/new-acct" + order = "https://example.com/acme/new-order" + authz = "https://example.com/acme/new-authz" + revoke = "https://example.com/acme/revoke-cert" + keychange = "https://example.com/acme/key-change" + metaTerms = "https://example.com/acme/terms/2017-5-30" + metaWebsite = "https://www.example.com/" + metaCAA = "example.com" + ) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{ + "newNonce": %q, + "newAccount": %q, + "newOrder": %q, + "newAuthz": %q, + "revokeCert": %q, + "keyChange": %q, + "meta": { + "termsOfService": %q, + "website": %q, + "caaIdentities": [%q], + "externalAccountRequired": true, + "profiles": { + "default": "Your favorite default profile", + "tlsserver": "New and improved", + "client": "For all your mutual TLS needs" + } + } + }`, nonce, reg, order, authz, revoke, keychange, metaTerms, metaWebsite, metaCAA) + })) + defer ts.Close() + c := &Client{DirectoryURL: ts.URL} + dir, err := c.Discover(context.Background()) + if err != nil { + t.Fatal(err) + } + expected := Profiles(map[string]string{"default": "Your favorite default profile", "tlsserver": "New and improved", "client": "For all your mutual TLS needs"}) + if dir.Profiles == nil { + t.Errorf("expected directory to be %+v; got nil", expected) + } + + for key, value := range dir.Profiles { + if expValue := expected.GetDescription(key); value != expValue { + t.Errorf("expected key %+q to have description %+q; got %+q", key, expected, value) + } + } } func TestRFC_popNonce(t *testing.T) { @@ -247,6 +302,27 @@ func (s *acmeServer) start() { return } + if r.URL.Path == "/directory-with-profiles" { + fmt.Fprintf(w, `{ + "newNonce": %q, + "newAccount": %q, + "newOrder": %q, + "newAuthz": %q, + "revokeCert": %q, + "keyChange": %q, + "meta": {"termsOfService": %q, "profiles": {"default": "Default", "server": "Server", "client": "Client"}} + }`, + s.url("/acme/new-nonce"), + s.url("/acme/new-account"), + s.url("/acme/new-order"), + s.url("/acme/new-authz"), + s.url("/acme/revoke-cert"), + s.url("/acme/key-change"), + s.url("/terms"), + ) + return + } + // All other responses contain a nonce value unconditionally. w.Header().Set("Replay-Nonce", s.nonce()) if r.URL.Path == "/acme/new-nonce" { @@ -795,6 +871,51 @@ func TestRFC_AuthorizeOrder(t *testing.T) { } } +func TestRFC_AuthorizeOrder_WithOrderProfile(t *testing.T) { + s := newACMEServer() + s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/accounts/1")) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status": "valid"}`)) + }) + s.handle("/acme/new-order", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", s.url("/orders/1")) + w.WriteHeader(http.StatusCreated) + fmt.Fprintf(w, `{ + "status": "pending", + "expires": "2019-09-01T00:00:00Z", + "notBefore": "2019-08-31T00:00:00Z", + "notAfter": "2019-09-02T00:00:00Z", + "identifiers": [{"type":"dns", "value":"example.org"}], + "authorizations": [%q] + }`, s.url("/authz/1")) + }) + s.start() + defer s.close() + + cl := &Client{Key: testKeyEC, DirectoryURL: s.url("/directory-with-profiles")} + o, err := cl.AuthorizeOrder(context.Background(), DomainIDs("example.org"), + WithOrderNotBefore(time.Date(2019, 8, 31, 0, 0, 0, 0, time.UTC)), + WithOrderNotAfter(time.Date(2019, 9, 2, 0, 0, 0, 0, time.UTC)), + WithOrderProfile("server"), + ) + if err != nil { + t.Fatal(err) + } + okOrder := &Order{ + URI: s.url("/orders/1"), + Status: StatusPending, + Expires: time.Date(2019, 9, 1, 0, 0, 0, 0, time.UTC), + NotBefore: time.Date(2019, 8, 31, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2019, 9, 2, 0, 0, 0, 0, time.UTC), + Identifiers: []AuthzID{{Type: "dns", Value: "example.org"}}, + AuthzURLs: []string{s.url("/authz/1")}, + } + if !reflect.DeepEqual(o, okOrder) { + t.Errorf("AuthorizeOrder = %+v; want %+v", o, okOrder) + } +} + func TestRFC_GetOrder(t *testing.T) { s := newACMEServer() s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) { @@ -892,11 +1013,17 @@ func TestRFC_WaitOrderError(t *testing.T) { s.handle("/orders/1", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Location", s.url("/orders/1")) w.WriteHeader(http.StatusOK) - s := StatusPending if count > 0 { - s = StatusInvalid + // https://www.rfc-editor.org/rfc/rfc8555#section-7.3.3 + errorData := `{ + "type": "urn:ietf:params:acme:error:userActionRequired", + "detail": "Terms of service have changed", + "instance": "https://example.com/acme/agreement/?token=W8Ih3PswD-8" + }` + fmt.Fprintf(w, `{"status": %q, "error": %s}`, StatusInvalid, errorData) + } else { + fmt.Fprintf(w, `{"status": %q}`, StatusPending) } - fmt.Fprintf(w, `{"status": %q}`, s) count++ }) s.start() @@ -917,6 +1044,13 @@ func TestRFC_WaitOrderError(t *testing.T) { if e.Status != StatusInvalid { t.Errorf("e.Status = %q; want %q", e.Status, StatusInvalid) } + if e.Problem == nil { + t.Errorf("e.Problem = nil") + } + expectedProbType := "urn:ietf:params:acme:error:userActionRequired" + if e.Problem.ProblemType != expectedProbType { + t.Errorf("e.Problem.ProblemType = %q; want %q", e.Problem.ProblemType, expectedProbType) + } } func TestRFC_CreateOrderCert(t *testing.T) { diff --git a/tempfork/acme/sync_to_upstream_test.go b/tempfork/acme/sync_to_upstream_test.go index e22c8c1a8..7a79de69a 100644 --- a/tempfork/acme/sync_to_upstream_test.go +++ b/tempfork/acme/sync_to_upstream_test.go @@ -45,7 +45,14 @@ func TestSyncedToUpstream(t *testing.T) { } for _, de := range ents { name := de.Name() - if name == "sync_to_upstream_test.go" { + switch name { + case "sync_to_upstream_test.go": + continue + case "pebble_test.go": + // We don't vendor upstream's pebble_test.go: its + // TestWithPebble downloads the Pebble module from + // outside our go.mod, then builds and runs its + // binaries during tests. continue } if !strings.HasSuffix(name, ".go") { diff --git a/tempfork/acme/types.go b/tempfork/acme/types.go index 0142469d8..71746c15e 100644 --- a/tempfork/acme/types.go +++ b/tempfork/acme/types.go @@ -56,6 +56,19 @@ // ErrNoAccount indicates that the Client's key has not been registered with the CA. ErrNoAccount = errors.New("acme: account does not exist") + + // errPreAuthorizationNotSupported indicates that the server does not + // support pre-authorization of identifiers. + errPreAuthorizationNotSupported = errors.New("acme: pre-authorization is not supported") + + // ErrCADoesNotSupportProfiles indicates that [WithOrderProfile] was + // included with a CA that does not advertise support for profiles in + // their directory. + ErrCADoesNotSupportProfiles = errors.New("acme: certificate authority does not support profiles") + + // ErrProfileNotInSetOfSupportedProfiles indicates that the profile + // specified with [WithOrderProfile} is not one supported by the CA + ErrProfileNotInSetOfSupportedProfiles = errors.New("acme: certificate authority does not advertise a profile with name") ) // A Subproblem describes an ACME subproblem as reported in an Error. @@ -150,17 +163,24 @@ func (a *AuthorizationError) Error() string { // OrderError is returned from Client's order related methods. // It indicates the order is unusable and the clients should start over with -// AuthorizeOrder. +// AuthorizeOrder. A Problem description may be provided with details on +// what caused the order to become unusable. // // The clients can still fetch the order object from CA using GetOrder // to inspect its state. type OrderError struct { OrderURL string Status string + // Problem is the error that occurred while processing the order. + Problem *Error } func (oe *OrderError) Error() string { - return fmt.Sprintf("acme: order %s status: %s", oe.OrderURL, oe.Status) + str := fmt.Sprintf("acme: order %s status: %s", oe.OrderURL, oe.Status) + if oe.Problem != nil { + str += fmt.Sprintf("; problem: %s", oe.Problem) + } + return str } // RateLimit reports whether err represents a rate limit error and @@ -308,6 +328,10 @@ type Directory struct { // ExternalAccountRequired indicates that the CA requires for all account-related // requests to include external account binding information. ExternalAccountRequired bool + + // Profiles indicates that the CA supports specifying a profile for an + // order. See also [WithOrderNotAfter]. + Profiles Profiles } // Order represents a client's request for a certificate. @@ -383,6 +407,15 @@ func WithOrderNotAfter(t time.Time) OrderOption { return orderNotAfterOpt(t) } +// WithOrderProfile sets an order's Profile field for servers which support +// profiles. +// See also: +// * https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/ +// * https://letsencrypt.org/docs/profiles/ +func WithOrderProfile(name string) OrderOption { + return orderProfileOpt(name) +} + type orderNotBeforeOpt time.Time func (orderNotBeforeOpt) privateOrderOpt() {} @@ -415,6 +448,10 @@ func WithOrderReplacesCertDER(der []byte) OrderOption { func (orderReplacesCertDER) privateOrderOpt() {} +type orderProfileOpt string + +func (orderProfileOpt) privateOrderOpt() {} + // Authorization encodes an authorization response. type Authorization struct { // URI uniquely identifies a authorization. @@ -643,7 +680,7 @@ func (*certOptKey) privateCertOpt() {} // // In TLS ChallengeCert methods, the template is also used as parent, // resulting in a self-signed certificate. -// The DNSNames field of t is always overwritten for tls-sni challenge certs. +// The DNSNames or IPAddresses fields of t are always overwritten for tls-alpn challenge certs. func WithTemplate(t *x509.Certificate) CertOption { return (*certOptTemplate)(t) } @@ -665,3 +702,18 @@ type RenewalInfo struct { SuggestedWindow RenewalInfoWindow `json:"suggestedWindow"` ExplanationURL string `json:"explanationURL"` } + +type Profiles map[string]string + +func (ps Profiles) isSupported() bool { + return len(ps) > 0 +} + +func (ps Profiles) GetDescription(name string) string { + return ps[name] +} + +func (ps Profiles) Has(name string) bool { + _, ok := ps[name] + return ok +} diff --git a/tempfork/acme/types_test.go b/tempfork/acme/types_test.go index 59ce7e760..2a59f6f0b 100644 --- a/tempfork/acme/types_test.go +++ b/tempfork/acme/types_test.go @@ -217,3 +217,19 @@ func TestErrorStringerWithSubproblems(t *testing.T) { t.Errorf("Unexpected error string: wanted %q, got %q", expectedStr, err.Error()) } } + +func TestOrderErrorProblem(t *testing.T) { + oe := &OrderError{ + OrderURL: "url", + Status: "invalid", + Problem: &Error{ + StatusCode: 400, + ProblemType: "type", + Detail: "detail", + }, + } + want := "acme: order url status: invalid; problem: 400 type: detail" + if got := oe.Error(); got != want { + t.Errorf("oe.Error() = %q, want %q", got, want) + } +} diff --git a/tempfork/acme/version_go112.go b/tempfork/acme/version_go112.go deleted file mode 100644 index cc5fab604..000000000 --- a/tempfork/acme/version_go112.go +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2019 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. - -//go:build go1.12 - -package acme - -import "runtime/debug" - -func init() { - // Set packageVersion if the binary was built in modules mode and x/crypto - // was not replaced with a different module. - info, ok := debug.ReadBuildInfo() - if !ok { - return - } - for _, m := range info.Deps { - if m.Path != "golang.org/x/crypto" { - continue - } - if m.Replace == nil { - packageVersion = m.Version - } - break - } -}