acme/internal: add a prober program
While working on the RFC 8555 implementation for golang/go#21081,
I've been also manually verifying the functionality against
various CAs both draft-02 and RFC versions.
This does not of course replace automated tests
yet it might be useful to others.
It is not a _test.go file because the program may require
interactive user input for dns-01 challenge types and needs
to flush logs to stderr during its run.
Change-Id: Ia324b08b30308441cc0705648e30c0112b0fa0c8
Reviewed-on: https://go-review.googlesource.com/c/crypto/+/194681
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/acme/internal/acmeprobe/prober.go b/acme/internal/acmeprobe/prober.go
new file mode 100644
index 0000000..68c52f2
--- /dev/null
+++ b/acme/internal/acmeprobe/prober.go
@@ -0,0 +1,477 @@
+// 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.
+
+// The acmeprober program runs against an actual ACME CA implementation.
+// It spins up an HTTP server to fulfill authorization challenges
+// or execute a DNS script to provision a response to dns-01 challenge.
+//
+// For http-01 and tls-alpn-01 challenge types this requires the ACME CA
+// to be able to reach the HTTP server.
+//
+// A usage example:
+//
+// go run prober.go \
+// -d https://acme-staging-v02.api.letsencrypt.org/directory \
+// -f order \
+// -t http-01 \
+// -a :8080 \
+// -domain some.example.org
+//
+// The above assumes a TCP tunnel from some.example.org:80 to 0.0.0.0:8080
+// in order for the test to be able to fulfill http-01 challenge.
+// To test tls-alpn-01 challenge, 443 port would need to be tunneled
+// to 0.0.0.0:8080.
+// When running with dns-01 challenge type, use -s argument instead of -a.
+package main
+
+import (
+ "context"
+ "crypto"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/pem"
+ "errors"
+ "flag"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "strings"
+ "time"
+
+ "golang.org/x/crypto/acme"
+)
+
+var (
+ // ACME CA directory URL.
+ // Let's Encrypt v1 prod: https://acme-v01.api.letsencrypt.org/directory
+ // Let's Encrypt v2 prod: https://acme-v02.api.letsencrypt.org/directory
+ // Let's Encrypt v2 staging: https://acme-staging-v02.api.letsencrypt.org/directory
+ // See the following for more CAs implementing ACME protocol:
+ // https://en.wikipedia.org/wiki/Automated_Certificate_Management_Environment#CAs_&_PKIs_that_offer_ACME_certificates
+ directory = flag.String("d", "", "ACME directory URL.")
+ reginfo = flag.String("r", "", "ACME account registration info.")
+ flow = flag.String("f", "", "Flow to run: order, preauthz (RFC8555) or preauthz02 (draft-02).")
+ chaltyp = flag.String("t", "", "Challenge type: tls-alpn-01, http-01 or dns-01.")
+ addr = flag.String("a", "", "Local server address for tls-alpn-01 and http-01.")
+ dnsscript = flag.String("s", "", "Script to run for provisioning dns-01 challenges.")
+ domain = flag.String("domain", "", "Space separate domain identifiers.")
+ ipaddr = flag.String("ip", "", "Space separate IP address identifiers.")
+)
+
+func main() {
+ flag.Usage = func() {
+ fmt.Fprintln(flag.CommandLine.Output(), `
+The prober program runs against an actual ACME CA implementation.
+It spins up an HTTP server to fulfill authorization challenges
+or execute a DNS script to provision a response to dns-01 challenge.
+
+For http-01 and tls-alpn-01 challenge types this requires the ACME CA
+to be able to reach the HTTP server.
+
+A usage example:
+
+ go run prober.go \
+ -d https://acme-staging-v02.api.letsencrypt.org/directory \
+ -f order \
+ -t http-01 \
+ -a :8080 \
+ -domain some.example.org
+
+The above assumes a TCP tunnel from some.example.org:80 to 0.0.0.0:8080
+in order for the test to be able to fulfill http-01 challenge.
+To test tls-alpn-01 challenge, 443 port would need to be tunneled
+to 0.0.0.0:8080.
+When running with dns-01 challenge type, use -s argument instead of -a.
+ `)
+ flag.PrintDefaults()
+ }
+ flag.Parse()
+
+ identifiers := acme.DomainIDs(strings.Fields(*domain)...)
+ identifiers = append(identifiers, acme.IPIDs(strings.Fields(*ipaddr)...)...)
+ if len(identifiers) == 0 {
+ log.Fatal("at least one domain or IP addr identifier is required")
+ }
+
+ // Duration of the whole run.
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
+ defer cancel()
+
+ // Create and register a new account.
+ akey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ log.Fatal(err)
+ }
+ cl := &acme.Client{Key: akey, DirectoryURL: *directory}
+ a := &acme.Account{Contact: strings.Fields(*reginfo)}
+ if _, err := cl.Register(ctx, a, acme.AcceptTOS); err != nil {
+ log.Fatalf("Register: %v", err)
+ }
+
+ // Run the desired flow test.
+ p := &prober{
+ client: cl,
+ chalType: *chaltyp,
+ localAddr: *addr,
+ dnsScript: *dnsscript,
+ }
+ switch *flow {
+ case "order":
+ p.runOrder(ctx, identifiers)
+ case "preauthz":
+ p.runPreauthz(ctx, identifiers)
+ case "preauthz02":
+ p.runPreauthzLegacy(ctx, identifiers)
+ default:
+ log.Fatalf("unknown flow: %q", *flow)
+ }
+ if len(p.errors) > 0 {
+ os.Exit(1)
+ }
+}
+
+type prober struct {
+ client *acme.Client
+ chalType string
+ localAddr string
+ dnsScript string
+
+ errors []error
+}
+
+func (p *prober) errorf(format string, a ...interface{}) {
+ err := fmt.Errorf(format, a...)
+ log.Print(err)
+ p.errors = append(p.errors, err)
+}
+
+func (p *prober) runOrder(ctx context.Context, identifiers []acme.AuthzID) {
+ // Create a new order and pick a challenge.
+ // Note that Let's Encrypt will reply with 400 error:malformed
+ // "NotBefore and NotAfter are not supported" when providing a NotAfter
+ // value like WithOrderNotAfter(time.Now().Add(24 * time.Hour)).
+ o, err := p.client.AuthorizeOrder(ctx, identifiers)
+ if err != nil {
+ log.Fatalf("AuthorizeOrder: %v", err)
+ }
+
+ var zurls []string
+ for _, u := range o.AuthzURLs {
+ z, err := p.client.GetAuthorization(ctx, u)
+ if err != nil {
+ log.Fatalf("GetAuthorization(%q): %v", u, err)
+ }
+ log.Printf("%+v", z)
+ if z.Status != acme.StatusPending {
+ log.Printf("authz status is %q; skipping", z.Status)
+ continue
+ }
+ if err := p.fulfill(ctx, z); err != nil {
+ log.Fatalf("fulfill(%s): %v", z.URI, err)
+ }
+ zurls = append(zurls, z.URI)
+ log.Printf("authorized for %+v", z.Identifier)
+ }
+
+ log.Print("all challenges are done")
+ if _, err := p.client.WaitOrder(ctx, o.URI); err != nil {
+ log.Fatalf("WaitOrder(%q): %v", o.URI, err)
+ }
+ csr, certkey := newCSR(identifiers)
+ der, curl, err := p.client.CreateOrderCert(ctx, o.FinalizeURL, csr, true)
+ if err != nil {
+ log.Fatalf("CreateOrderCert: %v", err)
+ }
+ log.Printf("cert URL: %s", curl)
+ if err := checkCert(der, identifiers); err != nil {
+ p.errorf("invalid cert: %v", err)
+ }
+
+ // Deactivate all authorizations we satisfied earlier.
+ for _, v := range zurls {
+ if err := p.client.RevokeAuthorization(ctx, v); err != nil {
+ p.errorf("RevokAuthorization(%q): %v", v, err)
+ continue
+ }
+ }
+ // Deactivate the account. We don't need it for any further calls.
+ if err := p.client.DeactivateReg(ctx); err != nil {
+ p.errorf("DeactivateReg: %v", err)
+ }
+ // Try revoking the issued cert using its private key.
+ if err := p.client.RevokeCert(ctx, certkey, der[0], acme.CRLReasonCessationOfOperation); err != nil {
+ p.errorf("RevokeCert: %v", err)
+ }
+}
+
+func (p *prober) runPreauthz(ctx context.Context, identifiers []acme.AuthzID) {
+ dir, err := p.client.Discover(ctx)
+ if dir.AuthzURL == "" {
+ log.Fatal("CA does not support pre-authorization")
+ }
+
+ var zurls []string
+ for _, id := range identifiers {
+ z, err := authorize(ctx, p.client, id)
+ if err != nil {
+ log.Fatalf("AuthorizeID(%+v): %v", z, err)
+ }
+ if z.Status == acme.StatusValid {
+ log.Printf("authz %s is valid; skipping", z.URI)
+ continue
+ }
+ if err := p.fulfill(ctx, z); err != nil {
+ log.Fatalf("fulfill(%s): %v", z.URI, err)
+ }
+ zurls = append(zurls, z.URI)
+ log.Printf("authorized for %+v", id)
+ }
+
+ // We should be all set now.
+ // Expect all authorizations to be satisfied.
+ log.Print("all challenges are done")
+ o, err := p.client.AuthorizeOrder(ctx, identifiers)
+ if err != nil {
+ log.Fatalf("AuthorizeOrder: %v", err)
+ }
+ waitCtx, cancel := context.WithTimeout(ctx, time.Minute)
+ defer cancel()
+ if _, err := p.client.WaitOrder(waitCtx, o.URI); err != nil {
+ log.Fatalf("WaitOrder(%q): %v", o.URI, err)
+ }
+ csr, certkey := newCSR(identifiers)
+ der, curl, err := p.client.CreateOrderCert(ctx, o.FinalizeURL, csr, true)
+ if err != nil {
+ log.Fatalf("CreateOrderCert: %v", err)
+ }
+ log.Printf("cert URL: %s", curl)
+ if err := checkCert(der, identifiers); err != nil {
+ p.errorf("invalid cert: %v", err)
+ }
+
+ // Deactivate all authorizations we satisfied earlier.
+ for _, v := range zurls {
+ if err := p.client.RevokeAuthorization(ctx, v); err != nil {
+ p.errorf("RevokeAuthorization(%q): %v", v, err)
+ continue
+ }
+ }
+ // Deactivate the account. We don't need it for any further calls.
+ if err := p.client.DeactivateReg(ctx); err != nil {
+ p.errorf("DeactivateReg: %v", err)
+ }
+ // Try revoking the issued cert using its private key.
+ if err := p.client.RevokeCert(ctx, certkey, der[0], acme.CRLReasonCessationOfOperation); err != nil {
+ p.errorf("RevokeCert: %v", err)
+ }
+}
+
+func (p *prober) runPreauthzLegacy(ctx context.Context, identifiers []acme.AuthzID) {
+ var zurls []string
+ for _, id := range identifiers {
+ z, err := authorize(ctx, p.client, id)
+ if err != nil {
+ log.Fatalf("AuthorizeID(%+v): %v", id, err)
+ }
+ if z.Status == acme.StatusValid {
+ log.Printf("authz %s is valid; skipping", z.URI)
+ continue
+ }
+ if err := p.fulfill(ctx, z); err != nil {
+ log.Fatalf("fulfill(%s): %v", z.URI, err)
+ }
+ zurls = append(zurls, z.URI)
+ log.Printf("authorized for %+v", id)
+ }
+
+ // We should be all set now.
+ log.Print("all authorizations are done")
+ csr, certkey := newCSR(identifiers)
+ der, curl, err := p.client.CreateCert(ctx, csr, 48*time.Hour, true)
+ if err != nil {
+ log.Fatalf("CreateCert: %v", err)
+ }
+ log.Printf("cert URL: %s", curl)
+ if err := checkCert(der, identifiers); err != nil {
+ p.errorf("invalid cert: %v", err)
+ }
+
+ // Deactivate all authorizations we satisfied earlier.
+ for _, v := range zurls {
+ if err := p.client.RevokeAuthorization(ctx, v); err != nil {
+ p.errorf("RevokAuthorization(%q): %v", v, err)
+ continue
+ }
+ }
+ // Try revoking the issued cert using its private key.
+ if err := p.client.RevokeCert(ctx, certkey, der[0], acme.CRLReasonCessationOfOperation); err != nil {
+ p.errorf("RevokeCert: %v", err)
+ }
+
+}
+
+func (p *prober) fulfill(ctx context.Context, z *acme.Authorization) error {
+ var chal *acme.Challenge
+ for i, c := range z.Challenges {
+ log.Printf("challenge %d: %+v", i, c)
+ if c.Type == p.chalType {
+ log.Printf("picked %s for authz %s", c.URI, z.URI)
+ chal = c
+ }
+ }
+ if chal == nil {
+ return fmt.Errorf("challenge type %q wasn't offered for authz %s", p.chalType, z.URI)
+ }
+
+ switch chal.Type {
+ case "tls-alpn-01":
+ return p.runTLSALPN01(ctx, z, chal)
+ case "http-01":
+ return p.runHTTP01(ctx, z, chal)
+ case "dns-01":
+ return p.runDNS01(ctx, z, chal)
+ default:
+ return fmt.Errorf("unknown challenge type %q", chal.Type)
+ }
+}
+
+func (p *prober) runTLSALPN01(ctx context.Context, z *acme.Authorization, chal *acme.Challenge) error {
+ tokenCert, err := p.client.TLSALPN01ChallengeCert(chal.Token, z.Identifier.Value)
+ if err != nil {
+ return fmt.Errorf("TLSALPN01ChallengeCert: %v", err)
+ }
+ s := &http.Server{
+ Addr: p.localAddr,
+ TLSConfig: &tls.Config{
+ NextProtos: []string{acme.ALPNProto},
+ GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
+ log.Printf("hello: %+v", hello)
+ return &tokenCert, nil
+ },
+ },
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ log.Printf("%s %s", r.Method, r.URL)
+ w.WriteHeader(http.StatusNotFound)
+ }),
+ }
+ go s.ListenAndServeTLS("", "")
+ defer s.Close()
+
+ if _, err := p.client.Accept(ctx, chal); err != nil {
+ return fmt.Errorf("Accept(%q): %v", chal.URI, err)
+ }
+ _, zerr := p.client.WaitAuthorization(ctx, z.URI)
+ return zerr
+}
+
+func (p *prober) runHTTP01(ctx context.Context, z *acme.Authorization, chal *acme.Challenge) error {
+ body, err := p.client.HTTP01ChallengeResponse(chal.Token)
+ if err != nil {
+ return fmt.Errorf("HTTP01ChallengeResponse: %v", err)
+ }
+ s := &http.Server{
+ Addr: p.localAddr,
+ Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ log.Printf("%s %s", r.Method, r.URL)
+ if r.URL.Path != p.client.HTTP01ChallengePath(chal.Token) {
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ w.Write([]byte(body))
+ }),
+ }
+ go s.ListenAndServe()
+ defer s.Close()
+
+ if _, err := p.client.Accept(ctx, chal); err != nil {
+ return fmt.Errorf("Accept(%q): %v", chal.URI, err)
+ }
+ _, zerr := p.client.WaitAuthorization(ctx, z.URI)
+ return zerr
+}
+
+func (p *prober) runDNS01(ctx context.Context, z *acme.Authorization, chal *acme.Challenge) error {
+ token, err := p.client.DNS01ChallengeRecord(chal.Token)
+ if err != nil {
+ return fmt.Errorf("DNS01ChallengeRecord: %v", err)
+ }
+
+ name := fmt.Sprintf("_acme-challenge.%s", z.Identifier.Value)
+ cmd := exec.CommandContext(ctx, p.dnsScript, name, token)
+ cmd.Stdin = os.Stdin
+ cmd.Stdout = os.Stdout
+ cmd.Stderr = os.Stderr
+ if err := cmd.Run(); err != nil {
+ return fmt.Errorf("%s: %v", p.dnsScript, err)
+ }
+
+ if _, err := p.client.Accept(ctx, chal); err != nil {
+ return fmt.Errorf("Accept(%q): %v", chal.URI, err)
+ }
+ _, zerr := p.client.WaitAuthorization(ctx, z.URI)
+ return zerr
+}
+
+func authorize(ctx context.Context, client *acme.Client, id acme.AuthzID) (*acme.Authorization, error) {
+ if id.Type == "ip" {
+ return client.AuthorizeIP(ctx, id.Value)
+ }
+ return client.Authorize(ctx, id.Value)
+}
+
+func checkCert(derChain [][]byte, id []acme.AuthzID) error {
+ if len(derChain) == 0 {
+ return errors.New("cert chain is zero bytes")
+ }
+ for i, b := range derChain {
+ crt, err := x509.ParseCertificate(b)
+ if err != nil {
+ return fmt.Errorf("%d: ParseCertificate: %v", i, err)
+ }
+ log.Printf("%d: serial: 0x%s", i, crt.SerialNumber)
+ log.Printf("%d: subject: %s", i, crt.Subject)
+ log.Printf("%d: issuer: %s", i, crt.Issuer)
+ log.Printf("%d: expires in %.1f day(s)", i, time.Until(crt.NotAfter).Hours()/24)
+ if i > 0 { // not a leaf cert
+ continue
+ }
+ p := &pem.Block{Type: "CERTIFICATE", Bytes: b}
+ log.Printf("%d: leaf:\n%s", i, pem.EncodeToMemory(p))
+ for _, v := range id {
+ if err := crt.VerifyHostname(v.Value); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func newCSR(identifiers []acme.AuthzID) ([]byte, crypto.Signer) {
+ var csr x509.CertificateRequest
+ for _, id := range identifiers {
+ switch id.Type {
+ case "dns":
+ csr.DNSNames = append(csr.DNSNames, id.Value)
+ case "ip":
+ csr.IPAddresses = append(csr.IPAddresses, net.ParseIP(id.Value))
+ default:
+ panic(fmt.Sprintf("newCSR: unknown identifier type %q", id.Type))
+ }
+ }
+ k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ panic(fmt.Sprintf("newCSR: ecdsa.GenerateKey for a cert: %v", err))
+ }
+ b, err := x509.CreateCertificateRequest(rand.Reader, &csr, k)
+ if err != nil {
+ panic(fmt.Sprintf("newCSR: x509.CreateCertificateRequest: %v", err))
+ }
+ return b, k
+}