Jelajahi Sumber

acme: initial import of ACME implementation

Originated from github.com/google/goacme.

This does not include goacme command line program
github.com/google/goacme/cmd/goacme. It will probably live somewhere
else.

Change-Id: I852d55c1a3673fab208e6e2aa19d22be42b8719f
Reviewed-on: https://go-review.googlesource.com/20837
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
Alex Vaghin 9 tahun lalu
induk
melakukan
1777f3ba8c

+ 473 - 0
acme/internal/acme/acme.go

@@ -0,0 +1,473 @@
+// Copyright 2015 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.
+
+// Package acme provides an ACME client implementation.
+// See https://ietf-wg-acme.github.io/acme/ for details.
+//
+// This package is a work in progress and makes no API stability promises.
+package acme
+
+import (
+	"bytes"
+	"crypto/rsa"
+	"encoding/base64"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"strconv"
+	"strings"
+	"time"
+
+	"golang.org/x/net/context"
+)
+
+// Client is an ACME client.
+type Client struct {
+	// HTTPClient optionally specifies an HTTP client to use
+	// instead of http.DefaultClient.
+	HTTPClient *http.Client
+
+	// Key is the account key used to register with a CA
+	// and sign requests.
+	Key *rsa.PrivateKey
+}
+
+// Discover performs ACME server discovery using the provided discovery endpoint URL.
+func (c *Client) Discover(url string) (*Directory, error) {
+	res, err := c.httpClient().Get(url)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+	if res.StatusCode != http.StatusOK {
+		return nil, responseError(res)
+	}
+	var v struct {
+		Reg    string `json:"new-reg"`
+		Authz  string `json:"new-authz"`
+		Cert   string `json:"new-cert"`
+		Revoke string `json:"revoke-cert"`
+		Meta   struct {
+			Terms   string   `json:"terms-of-service"`
+			Website string   `json:"website"`
+			CAA     []string `json:"caa-identities"`
+		}
+	}
+	if json.NewDecoder(res.Body).Decode(&v); err != nil {
+		return nil, err
+	}
+	return &Directory{
+		RegURL:    v.Reg,
+		AuthzURL:  v.Authz,
+		CertURL:   v.Cert,
+		RevokeURL: v.Revoke,
+		Terms:     v.Meta.Terms,
+		Website:   v.Meta.Website,
+		CAA:       v.Meta.CAA,
+	}, nil
+}
+
+// CreateCert requests a new certificate.
+// In the case where CA server does not provide the issued certificate in the response,
+// CreateCert will poll certURL using c.FetchCert, which will result in additional round-trips.
+// In such scenario the caller can cancel the polling with ctx.
+//
+// If the bundle is true, the returned value will also contain CA (the issuer) certificate.
+// The url argument is an Directory.CertURL value, typically obtained from c.Discover.
+// The csr is a DER encoded certificate signing request.
+func (c *Client) CreateCert(ctx context.Context, url string, csr []byte, exp time.Duration, bundle bool) (der [][]byte, certURL string, err error) {
+	req := struct {
+		Resource  string `json:"resource"`
+		CSR       string `json:"csr"`
+		NotBefore string `json:"notBefore,omitempty"`
+		NotAfter  string `json:"notAfter,omitempty"`
+	}{
+		Resource: "new-cert",
+		CSR:      base64.RawURLEncoding.EncodeToString(csr),
+	}
+	now := timeNow()
+	req.NotBefore = now.Format(time.RFC3339)
+	if exp > 0 {
+		req.NotAfter = now.Add(exp).Format(time.RFC3339)
+	}
+
+	res, err := c.postJWS(url, req)
+	if err != nil {
+		return nil, "", err
+	}
+	defer res.Body.Close()
+	if res.StatusCode != http.StatusCreated {
+		return nil, "", responseError(res)
+	}
+
+	curl := res.Header.Get("location") // cert permanent URL
+	if res.ContentLength == 0 {
+		// no cert in the body; poll until we get it
+		cert, err := c.FetchCert(ctx, curl, bundle)
+		return cert, curl, err
+	}
+	// slurp issued cert and ca, if requested
+	cert, err := responseCert(c.httpClient(), res, bundle)
+	return cert, curl, err
+}
+
+// FetchCert retrieves already issued certificate from the given url, in DER format.
+// It retries the request until the certificate is successfully retrieved,
+// context is cancelled by the caller or an error response is received.
+//
+// The returned value will also contain CA (the issuer) certificate if bundle == true.
+//
+// http.DefaultClient is used if client argument is nil.
+func (c *Client) FetchCert(ctx context.Context, url string, bundle bool) ([][]byte, error) {
+	for {
+		res, err := c.httpClient().Get(url)
+		if err != nil {
+			return nil, err
+		}
+		defer res.Body.Close()
+		if res.StatusCode == http.StatusOK {
+			return responseCert(c.httpClient(), res, bundle)
+		}
+		if res.StatusCode > 299 {
+			return nil, responseError(res)
+		}
+		d, err := retryAfter(res.Header.Get("retry-after"))
+		if err != nil {
+			d = 3 * time.Second
+		}
+		select {
+		case <-time.After(d):
+			// retry
+		case <-ctx.Done():
+			return nil, ctx.Err()
+		}
+	}
+}
+
+// Register creates a new account registration by following the "new-reg" flow.
+// It returns registered account. The a argument is not modified.
+//
+// The url argument is typically an Directory.RegURL obtained from c.Discover.
+func (c *Client) Register(url string, a *Account) (*Account, error) {
+	return c.doReg(url, "new-reg", a)
+}
+
+// GetReg retrieves an existing registration.
+// The url argument is an Account.URI, typically obtained from c.Register.
+func (c *Client) GetReg(url string) (*Account, error) {
+	a := &Account{URI: url}
+	return c.doReg(url, "reg", a)
+}
+
+// UpdateReg updates an existing registration.
+// It returns an updated account copy. The provided account is not modified.
+//
+// The url argument is an Account.URI, usually obtained with c.Register.
+func (c *Client) UpdateReg(url string, a *Account) (*Account, error) {
+	return c.doReg(url, "reg", a)
+}
+
+// Authorize performs the initial step in an authorization flow.
+// The caller will then need to choose from and perform a set of returned
+// challenges using c.Accept in order to successfully complete authorization.
+//
+// The url argument is an authz URL, usually obtained with c.Register.
+func (c *Client) Authorize(url, domain string) (*Authorization, error) {
+	type authzID struct {
+		Type  string `json:"type"`
+		Value string `json:"value"`
+	}
+	req := struct {
+		Resource   string  `json:"resource"`
+		Identifier authzID `json:"identifier"`
+	}{
+		Resource:   "new-authz",
+		Identifier: authzID{Type: "dns", Value: domain},
+	}
+	res, err := c.postJWS(url, req)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+	if res.StatusCode != http.StatusCreated {
+		return nil, responseError(res)
+	}
+
+	var v wireAuthz
+	if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
+		return nil, fmt.Errorf("Decode: %v", err)
+	}
+	if v.Status != StatusPending {
+		return nil, fmt.Errorf("Unexpected status: %s", v.Status)
+	}
+	return v.authorization(res.Header.Get("Location")), nil
+}
+
+// GetAuthz retrieves the current status of an authorization flow.
+//
+// A client typically polls an authz status using this method.
+func (c *Client) GetAuthz(url string) (*Authorization, error) {
+	res, err := c.httpClient().Get(url)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+	if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusAccepted {
+		return nil, responseError(res)
+	}
+	var v wireAuthz
+	if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
+		return nil, fmt.Errorf("Decode: %v", err)
+	}
+	return v.authorization(url), nil
+}
+
+// GetChallenge retrieves the current status of an challenge.
+//
+// A client typically polls a challenge status using this method.
+func (c *Client) GetChallenge(url string) (*Challenge, error) {
+	res, err := c.httpClient().Get(url)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+	if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusAccepted {
+		return nil, responseError(res)
+	}
+	v := wireChallenge{URI: url}
+	if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
+		return nil, fmt.Errorf("Decode: %v", err)
+	}
+	return v.challenge(), nil
+}
+
+// Accept informs the server that the client accepts one of its challenges
+// previously obtained with c.Authorize.
+//
+// The server will then perform the validation asynchronously.
+func (c *Client) Accept(chal *Challenge) (*Challenge, error) {
+	req := struct {
+		Resource string `json:"resource"`
+		Type     string `json:"type"`
+		Auth     string `json:"keyAuthorization"`
+	}{
+		Resource: "challenge",
+		Type:     chal.Type,
+		Auth:     keyAuth(&c.Key.PublicKey, chal.Token),
+	}
+	res, err := c.postJWS(chal.URI, req)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+	// Note: the protocol specifies 200 as the expected response code, but
+	// letsencrypt seems to be returning 202.
+	if res.StatusCode != http.StatusOK && res.StatusCode != http.StatusAccepted {
+		return nil, responseError(res)
+	}
+
+	var v wireChallenge
+	if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
+		return nil, fmt.Errorf("Decode: %v", err)
+	}
+	return v.challenge(), nil
+}
+
+// HTTP01Handler creates a new handler which responds to a http-01 challenge.
+// The token argument is a Challenge.Token value.
+func (c *Client) HTTP01Handler(token string) http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if !strings.HasSuffix(r.URL.Path, token) {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		w.Header().Set("content-type", "text/plain")
+		w.Write([]byte(keyAuth(&c.Key.PublicKey, token)))
+	})
+}
+
+func (c *Client) httpClient() *http.Client {
+	if c.HTTPClient != nil {
+		return c.HTTPClient
+	}
+	return http.DefaultClient
+}
+
+// postJWS signs body and posts it to the provided url.
+// The body argument must be JSON-serializable.
+func (c *Client) postJWS(url string, body interface{}) (*http.Response, error) {
+	nonce, err := fetchNonce(c.httpClient(), url)
+	if err != nil {
+		return nil, err
+	}
+	b, err := jwsEncodeJSON(body, c.Key, nonce)
+	if err != nil {
+		return nil, err
+	}
+	req, err := http.NewRequest("POST", url, bytes.NewReader(b))
+	if err != nil {
+		return nil, err
+	}
+	return c.httpClient().Do(req)
+}
+
+// doReg sends all types of registration requests.
+// The type of request is identified by typ argument, which is a "resource"
+// in the ACME spec terms.
+//
+// A non-nil acct argument indicates whether the intention is to mutate data
+// of the Account. Only Contact and Agreement of its fields are used
+// in such cases.
+//
+// The fields of acct will be populate with the server response
+// and may be overwritten.
+func (c *Client) doReg(url string, typ string, acct *Account) (*Account, error) {
+	req := struct {
+		Resource  string   `json:"resource"`
+		Contact   []string `json:"contact,omitempty"`
+		Agreement string   `json:"agreement,omitempty"`
+	}{
+		Resource: typ,
+	}
+	if acct != nil {
+		req.Contact = acct.Contact
+		req.Agreement = acct.AgreedTerms
+	}
+	res, err := c.postJWS(url, req)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+	if res.StatusCode < 200 || res.StatusCode > 299 {
+		return nil, responseError(res)
+	}
+
+	var v struct {
+		Contact        []string
+		Agreement      string
+		Authorizations string
+		Certificates   string
+	}
+	if err := json.NewDecoder(res.Body).Decode(&v); err != nil {
+		return nil, fmt.Errorf("Decode: %v", err)
+	}
+	return &Account{
+		URI:            res.Header.Get("Location"),
+		Contact:        v.Contact,
+		AgreedTerms:    v.Agreement,
+		CurrentTerms:   linkHeader(res.Header, "terms-of-service"),
+		Authz:          linkHeader(res.Header, "next"),
+		Authorizations: v.Authorizations,
+		Certificates:   v.Certificates,
+	}, nil
+}
+
+func responseCert(client *http.Client, res *http.Response, bundle bool) ([][]byte, error) {
+	b, err := ioutil.ReadAll(res.Body)
+	if err != nil {
+		return nil, fmt.Errorf("ReadAll: %v", err)
+	}
+	cert := [][]byte{b}
+	if !bundle {
+		return cert, nil
+	}
+
+	// append ca cert
+	up := linkHeader(res.Header, "up")
+	if up == "" {
+		return nil, errors.New("rel=up link not found")
+	}
+	res, err = client.Get(up)
+	if err != nil {
+		return nil, err
+	}
+	defer res.Body.Close()
+	if res.StatusCode != http.StatusOK {
+		return nil, responseError(res)
+	}
+	b, err = ioutil.ReadAll(res.Body)
+	if err != nil {
+		return nil, err
+	}
+	return append(cert, b), nil
+}
+
+// responseError creates an error of Error type from resp.
+func responseError(resp *http.Response) error {
+	// don't care if ReadAll returns an error:
+	// json.Unmarshal will fail in that case anyway
+	b, _ := ioutil.ReadAll(resp.Body)
+	e := struct {
+		Status int
+		Type   string
+		Detail string
+	}{
+		Status: resp.StatusCode,
+	}
+	if err := json.Unmarshal(b, &e); err != nil {
+		// this is not a regular error response:
+		// populate detail with anything we received,
+		// e.Status will already contain HTTP response code value
+		e.Detail = string(b)
+		if e.Detail == "" {
+			e.Detail = resp.Status
+		}
+	}
+	return &Error{
+		StatusCode:  e.Status,
+		ProblemType: e.Type,
+		Detail:      e.Detail,
+		Header:      resp.Header,
+	}
+}
+
+func fetchNonce(client *http.Client, url string) (string, error) {
+	resp, err := client.Head(url)
+	if err != nil {
+		return "", nil
+	}
+	defer resp.Body.Close()
+	enc := resp.Header.Get("replay-nonce")
+	if enc == "" {
+		return "", errors.New("nonce not found")
+	}
+	return enc, nil
+}
+
+func linkHeader(h http.Header, rel string) string {
+	for _, v := range h["Link"] {
+		parts := strings.Split(v, ";")
+		for _, p := range parts {
+			p = strings.TrimSpace(p)
+			if !strings.HasPrefix(p, "rel=") {
+				continue
+			}
+			if v := strings.Trim(p[4:], `"`); v == rel {
+				return strings.Trim(parts[0], "<>")
+			}
+		}
+	}
+	return ""
+}
+
+func retryAfter(v string) (time.Duration, error) {
+	if i, err := strconv.Atoi(v); err == nil {
+		return time.Duration(i) * time.Second, nil
+	}
+	t, err := http.ParseTime(v)
+	if err != nil {
+		return 0, err
+	}
+	return t.Sub(timeNow()), nil
+}
+
+// keyAuth generates a key authorization string for a given token.
+func keyAuth(pub *rsa.PublicKey, token string) string {
+	return fmt.Sprintf("%s.%s", token, JWKThumbprint(pub))
+}
+
+// timeNow is useful for testing for fixed current time.
+var timeNow = time.Now

+ 759 - 0
acme/internal/acme/acme_test.go

@@ -0,0 +1,759 @@
+// Copyright 2015 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.
+
+package acme
+
+import (
+	"crypto/rand"
+	"crypto/x509"
+	"crypto/x509/pkix"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"math/big"
+	"net/http"
+	"net/http/httptest"
+	"reflect"
+	"strings"
+	"testing"
+	"time"
+
+	"golang.org/x/net/context"
+)
+
+// Decodes a JWS-encoded request and unmarshals the decoded JSON into a provided
+// interface.
+func decodeJWSRequest(t *testing.T, v interface{}, r *http.Request) {
+	// Decode request
+	var req struct{ Payload string }
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		t.Fatal(err)
+	}
+	payload, err := base64.RawURLEncoding.DecodeString(req.Payload)
+	if err != nil {
+		t.Fatal(err)
+	}
+	err = json.Unmarshal(payload, v)
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestDiscover(t *testing.T) {
+	const (
+		reg    = "https://example.com/acme/new-reg"
+		authz  = "https://example.com/acme/new-authz"
+		cert   = "https://example.com/acme/new-cert"
+		revoke = "https://example.com/acme/revoke-cert"
+	)
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("content-type", "application/json")
+		fmt.Fprintf(w, `{
+			"new-reg": %q,
+			"new-authz": %q,
+			"new-cert": %q,
+			"revoke-cert": %q
+		}`, reg, authz, cert, revoke)
+	}))
+	defer ts.Close()
+	ep, err := (&Client{}).Discover(ts.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if ep.RegURL != reg {
+		t.Errorf("RegURL = %q; want %q", ep.RegURL, reg)
+	}
+	if ep.AuthzURL != authz {
+		t.Errorf("authzURL = %q; want %q", ep.AuthzURL, authz)
+	}
+	if ep.CertURL != cert {
+		t.Errorf("certURL = %q; want %q", ep.CertURL, cert)
+	}
+	if ep.RevokeURL != revoke {
+		t.Errorf("revokeURL = %q; want %q", ep.RevokeURL, revoke)
+	}
+}
+
+func TestRegister(t *testing.T) {
+	contacts := []string{"mailto:admin@example.com"}
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == "HEAD" {
+			w.Header().Set("replay-nonce", "test-nonce")
+			return
+		}
+		if r.Method != "POST" {
+			t.Errorf("r.Method = %q; want POST", r.Method)
+		}
+
+		var j struct {
+			Resource  string
+			Contact   []string
+			Agreement string
+		}
+		decodeJWSRequest(t, &j, r)
+
+		// Test request
+		if j.Resource != "new-reg" {
+			t.Errorf("j.Resource = %q; want new-reg", j.Resource)
+		}
+		if !reflect.DeepEqual(j.Contact, contacts) {
+			t.Errorf("j.Contact = %v; want %v", j.Contact, contacts)
+		}
+
+		w.Header().Set("Location", "https://ca.tld/acme/reg/1")
+		w.Header().Set("Link", `<https://ca.tld/acme/new-authz>;rel="next"`)
+		w.Header().Add("Link", `<https://ca.tld/acme/recover-reg>;rel="recover"`)
+		w.Header().Add("Link", `<https://ca.tld/acme/terms>;rel="terms-of-service"`)
+		w.WriteHeader(http.StatusCreated)
+		b, _ := json.Marshal(contacts)
+		fmt.Fprintf(w, `{
+			"key":%q,
+			"contact":%s
+		}`, testKeyThumbprint, b)
+	}))
+	defer ts.Close()
+
+	c := Client{Key: testKey}
+	a := &Account{Contact: contacts}
+	var err error
+	if a, err = c.Register(ts.URL, a); err != nil {
+		t.Fatal(err)
+	}
+	if a.URI != "https://ca.tld/acme/reg/1" {
+		t.Errorf("a.URI = %q; want https://ca.tld/acme/reg/1", a.URI)
+	}
+	if a.Authz != "https://ca.tld/acme/new-authz" {
+		t.Errorf("a.Authz = %q; want https://ca.tld/acme/new-authz", a.Authz)
+	}
+	if a.CurrentTerms != "https://ca.tld/acme/terms" {
+		t.Errorf("a.CurrentTerms = %q; want https://ca.tld/acme/terms", a.CurrentTerms)
+	}
+	if !reflect.DeepEqual(a.Contact, contacts) {
+		t.Errorf("a.Contact = %v; want %v", a.Contact, contacts)
+	}
+}
+
+func TestUpdateReg(t *testing.T) {
+	const terms = "https://ca.tld/acme/terms"
+	contacts := []string{"mailto:admin@example.com"}
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == "HEAD" {
+			w.Header().Set("replay-nonce", "test-nonce")
+			return
+		}
+		if r.Method != "POST" {
+			t.Errorf("r.Method = %q; want POST", r.Method)
+		}
+
+		var j struct {
+			Resource  string
+			Contact   []string
+			Agreement string
+		}
+		decodeJWSRequest(t, &j, r)
+
+		// Test request
+		if j.Resource != "reg" {
+			t.Errorf("j.Resource = %q; want reg", j.Resource)
+		}
+		if j.Agreement != terms {
+			t.Errorf("j.Agreement = %q; want %q", j.Agreement, terms)
+		}
+		if !reflect.DeepEqual(j.Contact, contacts) {
+			t.Errorf("j.Contact = %v; want %v", j.Contact, contacts)
+		}
+
+		w.Header().Set("Link", `<https://ca.tld/acme/new-authz>;rel="next"`)
+		w.Header().Add("Link", `<https://ca.tld/acme/recover-reg>;rel="recover"`)
+		w.Header().Add("Link", fmt.Sprintf(`<%s>;rel="terms-of-service"`, terms))
+		w.WriteHeader(http.StatusOK)
+		b, _ := json.Marshal(contacts)
+		fmt.Fprintf(w, `{
+			"key":%q,
+			"contact":%s,
+			"agreement":%q
+		}`, testKeyThumbprint, b, terms)
+	}))
+	defer ts.Close()
+
+	c := Client{Key: testKey}
+	a := &Account{Contact: contacts, AgreedTerms: terms}
+	var err error
+	if a, err = c.UpdateReg(ts.URL, a); err != nil {
+		t.Fatal(err)
+	}
+	if a.Authz != "https://ca.tld/acme/new-authz" {
+		t.Errorf("a.Authz = %q; want https://ca.tld/acme/new-authz", a.Authz)
+	}
+	if a.AgreedTerms != terms {
+		t.Errorf("a.AgreedTerms = %q; want %q", a.AgreedTerms, terms)
+	}
+	if a.CurrentTerms != terms {
+		t.Errorf("a.CurrentTerms = %q; want %q", a.CurrentTerms, terms)
+	}
+}
+
+func TestGetReg(t *testing.T) {
+	const terms = "https://ca.tld/acme/terms"
+	const newTerms = "https://ca.tld/acme/new-terms"
+	contacts := []string{"mailto:admin@example.com"}
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == "HEAD" {
+			w.Header().Set("replay-nonce", "test-nonce")
+			return
+		}
+		if r.Method != "POST" {
+			t.Errorf("r.Method = %q; want POST", r.Method)
+		}
+
+		var j struct {
+			Resource  string
+			Contact   []string
+			Agreement string
+		}
+		decodeJWSRequest(t, &j, r)
+
+		// Test request
+		if j.Resource != "reg" {
+			t.Errorf("j.Resource = %q; want reg", j.Resource)
+		}
+		if len(j.Contact) != 0 {
+			t.Errorf("j.Contact = %v", j.Contact)
+		}
+		if j.Agreement != "" {
+			t.Errorf("j.Agreement = %q", j.Agreement)
+		}
+
+		w.Header().Set("Link", `<https://ca.tld/acme/new-authz>;rel="next"`)
+		w.Header().Add("Link", `<https://ca.tld/acme/recover-reg>;rel="recover"`)
+		w.Header().Add("Link", fmt.Sprintf(`<%s>;rel="terms-of-service"`, newTerms))
+		w.WriteHeader(http.StatusOK)
+		b, _ := json.Marshal(contacts)
+		fmt.Fprintf(w, `{
+			"key":%q,
+			"contact":%s,
+			"agreement":%q
+		}`, testKeyThumbprint, b, terms)
+	}))
+	defer ts.Close()
+
+	c := Client{Key: testKey}
+	a, err := c.GetReg(ts.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if a.Authz != "https://ca.tld/acme/new-authz" {
+		t.Errorf("a.AuthzURL = %q; want https://ca.tld/acme/new-authz", a.Authz)
+	}
+	if a.AgreedTerms != terms {
+		t.Errorf("a.AgreedTerms = %q; want %q", a.AgreedTerms, terms)
+	}
+	if a.CurrentTerms != newTerms {
+		t.Errorf("a.CurrentTerms = %q; want %q", a.CurrentTerms, newTerms)
+	}
+}
+
+func TestAuthorize(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == "HEAD" {
+			w.Header().Set("replay-nonce", "test-nonce")
+			return
+		}
+		if r.Method != "POST" {
+			t.Errorf("r.Method = %q; want POST", r.Method)
+		}
+
+		var j struct {
+			Resource   string
+			Identifier struct {
+				Type  string
+				Value string
+			}
+		}
+		decodeJWSRequest(t, &j, r)
+
+		// Test request
+		if j.Resource != "new-authz" {
+			t.Errorf("j.Resource = %q; want new-authz", j.Resource)
+		}
+		if j.Identifier.Type != "dns" {
+			t.Errorf("j.Identifier.Type = %q; want dns", j.Identifier.Type)
+		}
+		if j.Identifier.Value != "example.com" {
+			t.Errorf("j.Identifier.Value = %q; want example.com", j.Identifier.Value)
+		}
+
+		w.Header().Set("Location", "https://ca.tld/acme/auth/1")
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprintf(w, `{
+			"identifier": {"type":"dns","value":"example.com"},
+			"status":"pending",
+			"challenges":[
+				{
+					"type":"http-01",
+					"status":"pending",
+					"uri":"https://ca.tld/acme/challenge/publickey/id1",
+					"token":"token1"
+				},
+				{
+					"type":"tls-sni-01",
+					"status":"pending",
+					"uri":"https://ca.tld/acme/challenge/publickey/id2",
+					"token":"token2"
+				}
+			],
+			"combinations":[[0],[1]]}`)
+	}))
+	defer ts.Close()
+
+	cl := Client{Key: testKey}
+	auth, err := cl.Authorize(ts.URL, "example.com")
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if auth.URI != "https://ca.tld/acme/auth/1" {
+		t.Errorf("URI = %q; want https://ca.tld/acme/auth/1", auth.URI)
+	}
+	if auth.Status != "pending" {
+		t.Errorf("Status = %q; want pending", auth.Status)
+	}
+	if auth.Identifier.Type != "dns" {
+		t.Errorf("Identifier.Type = %q; want dns", auth.Identifier.Type)
+	}
+	if auth.Identifier.Value != "example.com" {
+		t.Errorf("Identifier.Value = %q; want example.com", auth.Identifier.Value)
+	}
+
+	if n := len(auth.Challenges); n != 2 {
+		t.Fatalf("len(auth.Challenges) = %d; want 2", n)
+	}
+
+	c := auth.Challenges[0]
+	if c.Type != "http-01" {
+		t.Errorf("c.Type = %q; want http-01", c.Type)
+	}
+	if c.URI != "https://ca.tld/acme/challenge/publickey/id1" {
+		t.Errorf("c.URI = %q; want https://ca.tld/acme/challenge/publickey/id1", c.URI)
+	}
+	if c.Token != "token1" {
+		t.Errorf("c.Token = %q; want token1", c.Type)
+	}
+
+	c = auth.Challenges[1]
+	if c.Type != "tls-sni-01" {
+		t.Errorf("c.Type = %q; want tls-sni-01", c.Type)
+	}
+	if c.URI != "https://ca.tld/acme/challenge/publickey/id2" {
+		t.Errorf("c.URI = %q; want https://ca.tld/acme/challenge/publickey/id2", c.URI)
+	}
+	if c.Token != "token2" {
+		t.Errorf("c.Token = %q; want token2", c.Type)
+	}
+
+	combs := [][]int{{0}, {1}}
+	if !reflect.DeepEqual(auth.Combinations, combs) {
+		t.Errorf("auth.Combinations: %+v\nwant: %+v\n", auth.Combinations, combs)
+	}
+}
+
+func TestPollAuthz(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "GET" {
+			t.Errorf("r.Method = %q; want GET", r.Method)
+		}
+
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `{
+			"identifier": {"type":"dns","value":"example.com"},
+			"status":"pending",
+			"challenges":[
+				{
+					"type":"http-01",
+					"status":"pending",
+					"uri":"https://ca.tld/acme/challenge/publickey/id1",
+					"token":"token1"
+				},
+				{
+					"type":"tls-sni-01",
+					"status":"pending",
+					"uri":"https://ca.tld/acme/challenge/publickey/id2",
+					"token":"token2"
+				}
+			],
+			"combinations":[[0],[1]]}`)
+	}))
+	defer ts.Close()
+
+	cl := Client{Key: testKey}
+	auth, err := cl.GetAuthz(ts.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if auth.Status != "pending" {
+		t.Errorf("Status = %q; want pending", auth.Status)
+	}
+	if auth.Identifier.Type != "dns" {
+		t.Errorf("Identifier.Type = %q; want dns", auth.Identifier.Type)
+	}
+	if auth.Identifier.Value != "example.com" {
+		t.Errorf("Identifier.Value = %q; want example.com", auth.Identifier.Value)
+	}
+
+	if n := len(auth.Challenges); n != 2 {
+		t.Fatalf("len(set.Challenges) = %d; want 2", n)
+	}
+
+	c := auth.Challenges[0]
+	if c.Type != "http-01" {
+		t.Errorf("c.Type = %q; want http-01", c.Type)
+	}
+	if c.URI != "https://ca.tld/acme/challenge/publickey/id1" {
+		t.Errorf("c.URI = %q; want https://ca.tld/acme/challenge/publickey/id1", c.URI)
+	}
+	if c.Token != "token1" {
+		t.Errorf("c.Token = %q; want token1", c.Type)
+	}
+
+	c = auth.Challenges[1]
+	if c.Type != "tls-sni-01" {
+		t.Errorf("c.Type = %q; want tls-sni-01", c.Type)
+	}
+	if c.URI != "https://ca.tld/acme/challenge/publickey/id2" {
+		t.Errorf("c.URI = %q; want https://ca.tld/acme/challenge/publickey/id2", c.URI)
+	}
+	if c.Token != "token2" {
+		t.Errorf("c.Token = %q; want token2", c.Type)
+	}
+
+	combs := [][]int{{0}, {1}}
+	if !reflect.DeepEqual(auth.Combinations, combs) {
+		t.Errorf("auth.Combinations: %+v\nwant: %+v\n", auth.Combinations, combs)
+	}
+}
+
+func TestPollChallenge(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "GET" {
+			t.Errorf("r.Method = %q; want GET", r.Method)
+		}
+
+		w.WriteHeader(http.StatusOK)
+		fmt.Fprintf(w, `{
+			"type":"http-01",
+			"status":"pending",
+			"uri":"https://ca.tld/acme/challenge/publickey/id1",
+			"token":"token1"}`)
+	}))
+	defer ts.Close()
+
+	cl := Client{Key: testKey}
+	chall, err := cl.GetChallenge(ts.URL)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if chall.Status != "pending" {
+		t.Errorf("Status = %q; want pending", chall.Status)
+	}
+	if chall.Type != "http-01" {
+		t.Errorf("c.Type = %q; want http-01", chall.Type)
+	}
+	if chall.URI != "https://ca.tld/acme/challenge/publickey/id1" {
+		t.Errorf("c.URI = %q; want https://ca.tld/acme/challenge/publickey/id1", chall.URI)
+	}
+	if chall.Token != "token1" {
+		t.Errorf("c.Token = %q; want token1", chall.Type)
+	}
+}
+
+func TestAcceptChallenge(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == "HEAD" {
+			w.Header().Set("replay-nonce", "test-nonce")
+			return
+		}
+		if r.Method != "POST" {
+			t.Errorf("r.Method = %q; want POST", r.Method)
+		}
+
+		var j struct {
+			Resource string
+			Type     string
+			Auth     string `json:"keyAuthorization"`
+		}
+		decodeJWSRequest(t, &j, r)
+
+		// Test request
+		if j.Resource != "challenge" {
+			t.Errorf(`resource = %q; want "challenge"`, j.Resource)
+		}
+		if j.Type != "http-01" {
+			t.Errorf(`type = %q; want "http-01"`, j.Type)
+		}
+		keyAuth := "token1." + testKeyThumbprint
+		if j.Auth != keyAuth {
+			t.Errorf(`keyAuthorization = %q; want %q`, j.Auth, keyAuth)
+		}
+
+		// Respond to request
+		w.WriteHeader(http.StatusAccepted)
+		fmt.Fprintf(w, `{
+			"type":"http-01",
+			"status":"pending",
+			"uri":"https://ca.tld/acme/challenge/publickey/id1",
+			"token":"token1",
+			"keyAuthorization":%q
+		}`, keyAuth)
+	}))
+	defer ts.Close()
+
+	cl := Client{Key: testKey}
+	c, err := cl.Accept(&Challenge{
+		URI:   ts.URL,
+		Token: "token1",
+		Type:  "http-01",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if c.Type != "http-01" {
+		t.Errorf("c.Type = %q; want http-01", c.Type)
+	}
+	if c.URI != "https://ca.tld/acme/challenge/publickey/id1" {
+		t.Errorf("c.URI = %q; want https://ca.tld/acme/challenge/publickey/id1", c.URI)
+	}
+	if c.Token != "token1" {
+		t.Errorf("c.Token = %q; want token1", c.Type)
+	}
+}
+
+func TestNewCert(t *testing.T) {
+	notBefore := time.Now()
+	notAfter := notBefore.AddDate(0, 2, 0)
+	timeNow = func() time.Time { return notBefore }
+
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method == "HEAD" {
+			w.Header().Set("replay-nonce", "test-nonce")
+			return
+		}
+		if r.Method != "POST" {
+			t.Errorf("r.Method = %q; want POST", r.Method)
+		}
+
+		var j struct {
+			Resource  string `json:"resource"`
+			CSR       string `json:"csr"`
+			NotBefore string `json:"notBefore,omitempty"`
+			NotAfter  string `json:"notAfter,omitempty"`
+		}
+		decodeJWSRequest(t, &j, r)
+
+		// Test request
+		if j.Resource != "new-cert" {
+			t.Errorf(`resource = %q; want "new-cert"`, j.Resource)
+		}
+		if j.NotBefore != notBefore.Format(time.RFC3339) {
+			t.Errorf(`notBefore = %q; wanted %q`, j.NotBefore, notBefore.Format(time.RFC3339))
+		}
+		if j.NotAfter != notAfter.Format(time.RFC3339) {
+			t.Errorf(`notAfter = %q; wanted %q`, j.NotAfter, notAfter.Format(time.RFC3339))
+		}
+
+		// Respond to request
+		template := x509.Certificate{
+			SerialNumber: big.NewInt(int64(1)),
+			Subject: pkix.Name{
+				Organization: []string{"goacme"},
+			},
+			NotBefore: notBefore,
+			NotAfter:  notAfter,
+
+			KeyUsage:              x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
+			ExtKeyUsage:           []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+			BasicConstraintsValid: true,
+		}
+
+		sampleCert, err := x509.CreateCertificate(rand.Reader, &template, &template, &testKey.PublicKey, testKey)
+		if err != nil {
+			t.Fatalf("Error creating certificate: %v", err)
+		}
+
+		w.Header().Set("Location", "https://ca.tld/acme/cert/1")
+		w.WriteHeader(http.StatusCreated)
+		w.Write(sampleCert)
+	}))
+	defer ts.Close()
+
+	csr := x509.CertificateRequest{
+		Version: 0,
+		Subject: pkix.Name{
+			CommonName:   "example.com",
+			Organization: []string{"goacme"},
+		},
+	}
+	csrb, err := x509.CreateCertificateRequest(rand.Reader, &csr, testKey)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	c := Client{Key: testKey}
+	cert, certURL, err := c.CreateCert(context.Background(), ts.URL, csrb, notAfter.Sub(notBefore), false)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cert == nil {
+		t.Errorf("cert is nil")
+	}
+	if certURL != "https://ca.tld/acme/cert/1" {
+		t.Errorf("certURL = %q; want https://ca.tld/acme/cert/1", certURL)
+	}
+}
+
+func TestFetchCert(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Write([]byte{1})
+	}))
+	defer ts.Close()
+	res, err := (&Client{}).FetchCert(context.Background(), ts.URL, false)
+	if err != nil {
+		t.Fatalf("FetchCert: %v", err)
+	}
+	cert := [][]byte{{1}}
+	if !reflect.DeepEqual(res, cert) {
+		t.Errorf("res = %v; want %v", res, cert)
+	}
+}
+
+func TestFetchCertRetry(t *testing.T) {
+	var count int
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if count < 1 {
+			w.Header().Set("retry-after", "0")
+			w.WriteHeader(http.StatusAccepted)
+			count++
+			return
+		}
+		w.Write([]byte{1})
+	}))
+	defer ts.Close()
+	res, err := (&Client{}).FetchCert(context.Background(), ts.URL, false)
+	if err != nil {
+		t.Fatalf("FetchCert: %v", err)
+	}
+	cert := [][]byte{{1}}
+	if !reflect.DeepEqual(res, cert) {
+		t.Errorf("res = %v; want %v", res, cert)
+	}
+}
+
+func TestFetchCertCancel(t *testing.T) {
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("retry-after", "0")
+		w.WriteHeader(http.StatusAccepted)
+	}))
+	defer ts.Close()
+	ctx, cancel := context.WithCancel(context.Background())
+	done := make(chan struct{})
+	var err error
+	go func() {
+		_, err = (&Client{}).FetchCert(ctx, ts.URL, false)
+		close(done)
+	}()
+	cancel()
+	<-done
+	if err != context.Canceled {
+		t.Errorf("err = %v; want %v", err, context.Canceled)
+	}
+}
+
+func TestFetchNonce(t *testing.T) {
+	tests := []struct {
+		code  int
+		nonce string
+	}{
+		{http.StatusOK, "nonce1"},
+		{http.StatusBadRequest, "nonce2"},
+		{http.StatusOK, ""},
+	}
+	var i int
+	ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != "HEAD" {
+			t.Errorf("%d: r.Method = %q; want HEAD", i, r.Method)
+		}
+		w.Header().Set("replay-nonce", tests[i].nonce)
+		w.WriteHeader(tests[i].code)
+	}))
+	defer ts.Close()
+	for ; i < len(tests); i++ {
+		test := tests[i]
+		n, err := fetchNonce(http.DefaultClient, ts.URL)
+		if n != test.nonce {
+			t.Errorf("%d: n=%q; want %q", i, n, test.nonce)
+		}
+		switch {
+		case err == nil && test.nonce == "":
+			t.Errorf("%d: n=%q, err=%v; want non-nil error", i, n, err)
+		case err != nil && test.nonce != "":
+			t.Errorf("%d: n=%q, err=%v; want %q", i, n, err, test.nonce)
+		}
+	}
+}
+
+func TestLinkHeader(t *testing.T) {
+	h := http.Header{"Link": {
+		`<https://example.com/acme/new-authz>;rel="next"`,
+		`<https://example.com/acme/recover-reg>; rel=recover`,
+		`<https://example.com/acme/terms>; foo=bar; rel="terms-of-service"`,
+	}}
+	tests := []struct{ in, out string }{
+		{"next", "https://example.com/acme/new-authz"},
+		{"recover", "https://example.com/acme/recover-reg"},
+		{"terms-of-service", "https://example.com/acme/terms"},
+		{"empty", ""},
+	}
+	for i, test := range tests {
+		if v := linkHeader(h, test.in); v != test.out {
+			t.Errorf("%d: parseLinkHeader(%q): %q; want %q", i, test.in, v, test.out)
+		}
+	}
+}
+
+func TestErrorResponse(t *testing.T) {
+	s := `{
+		"status": 400,
+		"type": "urn:acme:error:xxx",
+		"detail": "text"
+	}`
+	res := &http.Response{
+		StatusCode: 400,
+		Status:     "400 Bad Request",
+		Body:       ioutil.NopCloser(strings.NewReader(s)),
+		Header:     http.Header{"X-Foo": {"bar"}},
+	}
+	err := responseError(res)
+	v, ok := err.(*Error)
+	if !ok {
+		t.Fatalf("err = %+v (%T); want *Error type", err, err)
+	}
+	if v.StatusCode != 400 {
+		t.Errorf("v.StatusCode = %v; want 400", v.StatusCode)
+	}
+	if v.ProblemType != "urn:acme:error:xxx" {
+		t.Errorf("v.ProblemType = %q; want urn:acme:error:xxx", v.ProblemType)
+	}
+	if v.Detail != "text" {
+		t.Errorf("v.Detail = %q; want text", v.Detail)
+	}
+	if !reflect.DeepEqual(v.Header, res.Header) {
+		t.Errorf("v.Header = %+v; want %+v", v.Header, res.Header)
+	}
+}

+ 67 - 0
acme/internal/acme/jws.go

@@ -0,0 +1,67 @@
+// Copyright 2015 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.
+
+package acme
+
+import (
+	"crypto"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha256"
+	"encoding/base64"
+	"encoding/json"
+	"fmt"
+	"math/big"
+)
+
+// jwsEncodeJSON signs claimset using provided key and a nonce.
+// The result is serialized in JSON format.
+// See https://tools.ietf.org/html/rfc7515#section-7.
+func jwsEncodeJSON(claimset interface{}, key *rsa.PrivateKey, nonce string) ([]byte, error) {
+	jwk := jwkEncode(&key.PublicKey)
+	phead := fmt.Sprintf(`{"alg":"RS256","jwk":%s,"nonce":%q}`, jwk, nonce)
+	phead = base64.RawURLEncoding.EncodeToString([]byte(phead))
+	cs, err := json.Marshal(claimset)
+	if err != nil {
+		return nil, err
+	}
+	payload := base64.RawURLEncoding.EncodeToString(cs)
+	h := sha256.New()
+	h.Write([]byte(phead + "." + payload))
+	sig, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, h.Sum(nil))
+	if err != nil {
+		return nil, err
+	}
+	enc := struct {
+		Protected string `json:"protected"`
+		Payload   string `json:"payload"`
+		Sig       string `json:"signature"`
+	}{
+		Protected: phead,
+		Payload:   payload,
+		Sig:       base64.RawURLEncoding.EncodeToString(sig),
+	}
+	return json.Marshal(&enc)
+}
+
+// jwkEncode encodes public part of an RSA key into a JWK.
+// The result is also suitable for creating a JWK thumbprint.
+func jwkEncode(pub *rsa.PublicKey) string {
+	n := pub.N
+	e := big.NewInt(int64(pub.E))
+	// fields order is important
+	// see https://tools.ietf.org/html/rfc7638#section-3.3 for details
+	return fmt.Sprintf(`{"e":"%s","kty":"RSA","n":"%s"}`,
+		base64.RawURLEncoding.EncodeToString(e.Bytes()),
+		base64.RawURLEncoding.EncodeToString(n.Bytes()),
+	)
+}
+
+// JWKThumbprint creates a JWK thumbprint out of pub
+// as specified in https://tools.ietf.org/html/rfc7638.
+func JWKThumbprint(pub *rsa.PublicKey) string {
+	jwk := jwkEncode(pub)
+	b := sha256.Sum256([]byte(jwk))
+	return base64.RawURLEncoding.EncodeToString(b[:])
+}

+ 139 - 0
acme/internal/acme/jws_test.go

@@ -0,0 +1,139 @@
+// Copyright 2015 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.
+
+package acme
+
+import (
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/json"
+	"encoding/pem"
+	"math/big"
+	"testing"
+)
+
+const testKeyPEM = `
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEA4xgZ3eRPkwoRvy7qeRUbmMDe0V+xH9eWLdu0iheeLlrmD2mq
+WXfP9IeSKApbn34g8TuAS9g5zhq8ELQ3kmjr+KV86GAMgI6VAcGlq3QrzpTCf/30
+Ab7+zawrfRaFONa1HwEzPY1KHnGVkxJc85gNkwYI9SY2RHXtvln3zs5wITNrdosq
+EXeaIkVYBEhbhNu54pp3kxo6TuWLi9e6pXeWetEwmlBwtWZlPoib2j3TxLBksKZf
+oyFyek380mHgJAumQ/I2fjj98/97mk3ihOY4AgVdCDj1z/GCoZkG5Rq7nbCGyosy
+KWyDX00Zs+nNqVhoLeIvXC4nnWdJMZ6rogxyQQIDAQABAoIBACIEZTOI1Kao9nmV
+9IeIsuaR1Y61b9neOF/MLmIVIZu+AAJFCMB4Iw11FV6sFodwpEyeZhx2WkpWVN+H
+r19eGiLX3zsL0DOdqBJoSIHDWCCMxgnYJ6nvS0nRxX3qVrBp8R2g12Ub+gNPbmFm
+ecf/eeERIVxfifd9VsyRu34eDEvcmKFuLYbElFcPh62xE3x12UZvV/sN7gXbawpP
+G+w255vbE5MoaKdnnO83cTFlcHvhn24M/78qP7Te5OAeelr1R89kYxQLpuGe4fbS
+zc6E3ym5Td6urDetGGrSY1Eu10/8sMusX+KNWkm+RsBRbkyKq72ks/qKpOxOa+c6
+9gm+Y8ECgYEA/iNUyg1ubRdH11p82l8KHtFC1DPE0V1gSZsX29TpM5jS4qv46K+s
+8Ym1zmrORM8x+cynfPx1VQZQ34EYeCMIX212ryJ+zDATl4NE0I4muMvSiH9vx6Xc
+7FmhNnaYzPsBL5Tm9nmtQuP09YEn8poiOJFiDs/4olnD5ogA5O4THGkCgYEA5MIL
+qWYBUuqbEWLRtMruUtpASclrBqNNsJEsMGbeqBJmoMxdHeSZckbLOrqm7GlMyNRJ
+Ne/5uWRGSzaMYuGmwsPpERzqEvYFnSrpjW5YtXZ+JtxFXNVfm9Z1gLLgvGpOUCIU
+RbpoDckDe1vgUuk3y5+DjZihs+rqIJ45XzXTzBkCgYBWuf3segruJZy5rEKhTv+o
+JqeUvRn0jNYYKFpLBeyTVBrbie6GkbUGNIWbrK05pC+c3K9nosvzuRUOQQL1tJbd
+4gA3oiD9U4bMFNr+BRTHyZ7OQBcIXdz3t1qhuHVKtnngIAN1p25uPlbRFUNpshnt
+jgeVoHlsBhApcs5DUc+pyQKBgDzeHPg/+g4z+nrPznjKnktRY1W+0El93kgi+J0Q
+YiJacxBKEGTJ1MKBb8X6sDurcRDm22wMpGfd9I5Cv2v4GsUsF7HD/cx5xdih+G73
+c4clNj/k0Ff5Nm1izPUno4C+0IOl7br39IPmfpSuR6wH/h6iHQDqIeybjxyKvT1G
+N0rRAoGBAKGD+4ZI/E1MoJ5CXB8cDDMHagbE3cq/DtmYzE2v1DFpQYu5I4PCm5c7
+EQeIP6dZtv8IMgtGIb91QX9pXvP0aznzQKwYIA8nZgoENCPfiMTPiEDT9e/0lObO
+9XWsXpbSTsRPj0sv1rB+UzBJ0PgjK4q2zOF0sNo7b1+6nlM3BWPx
+-----END RSA PRIVATE KEY----
+`
+
+// This thumbprint is for the testKey defined above.
+const testKeyThumbprint = "6nicxzh6WETQlrvdchkz-U3e3DOQZ4heJKU63rfqMqQ"
+
+var testKey *rsa.PrivateKey
+
+func init() {
+	d, _ := pem.Decode([]byte(testKeyPEM))
+	if d == nil {
+		panic("no block found in testKeyPEM")
+	}
+	var err error
+	testKey, err = x509.ParsePKCS1PrivateKey(d.Bytes)
+	if err != nil {
+		panic(err.Error())
+	}
+}
+
+func TestJWSEncodeJSON(t *testing.T) {
+	claims := struct{ Msg string }{"Hello JWS"}
+	// JWS signed with testKey and "nonce" as the nonce value
+	// JSON-serialized JWS fields are split for easier testing
+	const (
+		// {"alg":"RS256","jwk":{"e":"AQAB","kty":"RSA","n":"..."},"nonce":"nonce"}
+		protected = "eyJhbGciOiJSUzI1NiIsImp3ayI6eyJlIjoiQVFBQiIsImt0eSI6" +
+			"IlJTQSIsIm4iOiI0eGdaM2VSUGt3b1J2eTdxZVJVYm1NRGUwVi14" +
+			"SDllV0xkdTBpaGVlTGxybUQybXFXWGZQOUllU0tBcGJuMzRnOFR1" +
+			"QVM5ZzV6aHE4RUxRM2ttanItS1Y4NkdBTWdJNlZBY0dscTNRcnpw" +
+			"VENmXzMwQWI3LXphd3JmUmFGT05hMUh3RXpQWTFLSG5HVmt4SmM4" +
+			"NWdOa3dZSTlTWTJSSFh0dmxuM3pzNXdJVE5yZG9zcUVYZWFJa1ZZ" +
+			"QkVoYmhOdTU0cHAza3hvNlR1V0xpOWU2cFhlV2V0RXdtbEJ3dFda" +
+			"bFBvaWIyajNUeExCa3NLWmZveUZ5ZWszODBtSGdKQXVtUV9JMmZq" +
+			"ajk4Xzk3bWszaWhPWTRBZ1ZkQ0RqMXpfR0NvWmtHNVJxN25iQ0d5" +
+			"b3N5S1d5RFgwMFpzLW5OcVZob0xlSXZYQzRubldkSk1aNnJvZ3h5" +
+			"UVEifSwibm9uY2UiOiJub25jZSJ9"
+		// {"Msg":"Hello JWS"}
+		payload   = "eyJNc2ciOiJIZWxsbyBKV1MifQ"
+		signature = "eAGUikStX_UxyiFhxSLMyuyBcIB80GeBkFROCpap2sW3EmkU_ggF" +
+			"knaQzxrTfItICSAXsCLIquZ5BbrSWA_4vdEYrwWtdUj7NqFKjHRa" +
+			"zpLHcoR7r1rEHvkoP1xj49lS5fc3Wjjq8JUhffkhGbWZ8ZVkgPdC" +
+			"4tMBWiQDoth-x8jELP_3LYOB_ScUXi2mETBawLgOT2K8rA0Vbbmx" +
+			"hWNlOWuUf-8hL5YX4IOEwsS8JK_TrTq5Zc9My0zHJmaieqDV0UlP" +
+			"k0onFjPFkGm7MrPSgd0MqRG-4vSAg2O4hDo7rKv4n8POjjXlNQvM" +
+			"9IPLr8qZ7usYBKhEGwX3yq_eicAwBw"
+	)
+
+	b, err := jwsEncodeJSON(claims, testKey, "nonce")
+	if err != nil {
+		t.Fatal(err)
+	}
+	var jws struct{ Protected, Payload, Signature string }
+	if err := json.Unmarshal(b, &jws); err != nil {
+		t.Fatal(err)
+	}
+	if jws.Protected != protected {
+		t.Errorf("protected:\n%s\nwant:\n%s", jws.Protected, protected)
+	}
+	if jws.Payload != payload {
+		t.Errorf("payload:\n%s\nwant:\n%s", jws.Payload, payload)
+	}
+	if jws.Signature != signature {
+		t.Errorf("signature:\n%s\nwant:\n%s", jws.Signature, signature)
+	}
+}
+
+func TestJWKThumbprint(t *testing.T) {
+	// Key example from RFC 7638
+	const base64N = "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAt" +
+		"VT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn6" +
+		"4tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FD" +
+		"W2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n9" +
+		"1CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINH" +
+		"aQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw"
+	const base64E = "AQAB"
+	const expected = "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
+
+	bytes, err := base64.RawURLEncoding.DecodeString(base64N)
+	if err != nil {
+		t.Fatalf("Error parsing example key N: %v", err)
+	}
+	n := new(big.Int).SetBytes(bytes)
+
+	bytes, err = base64.RawURLEncoding.DecodeString(base64E)
+	if err != nil {
+		t.Fatalf("Error parsing example key E: %v", err)
+	}
+	e := new(big.Int).SetBytes(bytes)
+
+	pub := &rsa.PublicKey{N: n, E: int(e.Uint64())}
+	th := JWKThumbprint(pub)
+	if th != expected {
+		t.Errorf("th = %q; want %q", th, expected)
+	}
+}

+ 181 - 0
acme/internal/acme/types.go

@@ -0,0 +1,181 @@
+package acme
+
+import (
+	"fmt"
+	"net/http"
+)
+
+// ACME server response statuses used to describe Authorization and Challenge states.
+const (
+	StatusUnknown    = "unknown"
+	StatusPending    = "pending"
+	StatusProcessing = "processing"
+	StatusValid      = "valid"
+	StatusInvalid    = "invalid"
+	StatusRevoked    = "revoked"
+)
+
+// Account is a user account. It is associated with a private key.
+type Account struct {
+	// URI is the account unique ID, which is also a URL used to retrieve
+	// account data from the CA.
+	URI string
+
+	// Contact is a slice of contact info used during registration.
+	Contact []string
+
+	// The terms user has agreed to.
+	// Zero value indicates that the user hasn't agreed yet.
+	AgreedTerms string
+
+	// Actual terms of a CA.
+	CurrentTerms string
+
+	// Authz is the authorization URL used to initiate a new authz flow.
+	Authz string
+
+	// Authorizations is a URI from which a list of authorizations
+	// granted to this account can be fetched via a GET request.
+	Authorizations string
+
+	// Certificates is a URI from which a list of certificates
+	// issued for this account can be fetched via a GET request.
+	Certificates string
+}
+
+// Directory is ACME server discovery data.
+type Directory struct {
+	// RegURL is an account endpoint URL, allowing for creating new
+	// and modifying existing accounts.
+	RegURL string
+
+	// AuthzURL is used to initiate Identifier Authorization flow.
+	AuthzURL string
+
+	// CertURL is a new certificate issuance endpoint URL.
+	CertURL string
+
+	// RevokeURL is used to initiate a certificate revocation flow.
+	RevokeURL string
+
+	// Term is a URI identifying the current terms of service.
+	Terms string
+
+	// Website is an HTTP or HTTPS URL locating a website
+	// providing more information about the ACME server.
+	Website string
+
+	// CAA consists of lowercase hostname elements, which the ACME server
+	// recognises as referring to itself for the purposes of CAA record validation
+	// as defined in RFC6844.
+	CAA []string
+}
+
+// Challenge encodes a returned CA challenge.
+type Challenge struct {
+	// Type is the challenge type, e.g. "http-01", "tls-sni-02", "dns-01".
+	Type string
+
+	// URI is where a challenge response can be posted to.
+	URI string
+
+	// Token is a random value that uniquely identifies the challenge.
+	Token string
+
+	// Status identifies the status of this challenge.
+	Status string
+}
+
+// Authorization encodes an authorization response.
+type Authorization struct {
+	// URI uniquely identifies a authorization.
+	URI string
+
+	// Status identifies the status of an authorization.
+	Status string
+
+	// Identifier is what the account is authorized to represent.
+	Identifier AuthzID
+
+	// Challenges that the client needs to fulfill in order to prove possession
+	// of the identifier (for pending authorizations).
+	// For final authorizations, the challenges that were used.
+	Challenges []*Challenge
+
+	// A collection of sets of challenges, each of which would be sufficient
+	// to prove possession of the identifier.
+	// Clients must complete a set of challenges that covers at least one set.
+	// Challenges are identified by their indices in the challenges array.
+	// If this field is empty, the client needs to complete all challenges.
+	Combinations [][]int
+}
+
+// AuthzID is an identifier that an account is authorized to represent.
+type AuthzID struct {
+	Type  string // The type of identifier, e.g. "dns".
+	Value string // The identifier itself, e.g. "example.org".
+}
+
+// Error is an ACME error, defined in Problem Details for HTTP APIs doc
+// http://tools.ietf.org/html/draft-ietf-appsawg-http-problem.
+type Error struct {
+	// StatusCode is The HTTP status code generated by the origin server.
+	StatusCode int
+	// ProblemType is a URI reference that identifies the problem type,
+	// typically in a "urn:acme:error:xxx" form.
+	ProblemType string
+	// Detail is a human-readable explanation specific to this occurrence of the problem.
+	Detail string
+	// Header is the original server error response headers.
+	Header http.Header
+}
+
+func (e *Error) Error() string {
+	return fmt.Sprintf("%d %s: %s", e.StatusCode, e.ProblemType, e.Detail)
+}
+
+// wireAuthz is ACME JSON representation of Authorization objects.
+type wireAuthz struct {
+	Status       string
+	Challenges   []wireChallenge
+	Combinations [][]int
+	Identifier   struct {
+		Type  string
+		Value string
+	}
+}
+
+func (z *wireAuthz) authorization(uri string) *Authorization {
+	a := &Authorization{
+		URI:          uri,
+		Status:       z.Status,
+		Identifier:   AuthzID{Type: z.Identifier.Type, Value: z.Identifier.Value},
+		Combinations: z.Combinations, // shallow copy
+		Challenges:   make([]*Challenge, len(z.Challenges)),
+	}
+	for i, v := range z.Challenges {
+		a.Challenges[i] = v.challenge()
+	}
+	return a
+}
+
+// wireChallenge is ACME JSON challenge representation.
+type wireChallenge struct {
+	URI    string `json:"uri"`
+	Type   string
+	Token  string
+	Status string
+}
+
+func (c *wireChallenge) challenge() *Challenge {
+	v := &Challenge{
+		URI:    c.URI,
+		Type:   c.Type,
+		Token:  c.Token,
+		Status: c.Status,
+	}
+	if v.Status == "" {
+		v.Status = StatusPending
+	}
+	return v
+}