acme: add external account binding support

Implements https://tools.ietf.org/html/rfc8555#section-7.3.4

Fixes golang/go#41430

Co-authored-by: James Munnelly <james@munnelly.eu>
Change-Id: Icd0337fddbff49e7e79fb9105c2679609f990285
Reviewed-on: https://go-review.googlesource.com/c/crypto/+/269279
Run-TryBot: Katie Hockman <katie@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Trust: Katie Hockman <katie@golang.org>
Trust: Roland Shoemaker <roland@golang.org>
Reviewed-by: Roland Shoemaker <roland@golang.org>
diff --git a/acme/acme.go b/acme/acme.go
index 6e6c9d1..174cfe8 100644
--- a/acme/acme.go
+++ b/acme/acme.go
@@ -363,6 +363,10 @@
 // Also see Error's Instance field for when a CA requires already registered accounts to agree
 // to an updated Terms of Service.
 func (c *Client) Register(ctx context.Context, acct *Account, prompt func(tosURL string) bool) (*Account, error) {
+	if c.Key == nil {
+		return nil, errors.New("acme: client.Key must be set to Register")
+	}
+
 	dir, err := c.Discover(ctx)
 	if err != nil {
 		return nil, err
diff --git a/acme/acme_test.go b/acme/acme_test.go
index de8bea0..db9718a 100644
--- a/acme/acme_test.go
+++ b/acme/acme_test.go
@@ -188,6 +188,31 @@
 	}
 }
 
+func TestRegisterWithoutKey(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
+		}
+		w.WriteHeader(http.StatusCreated)
+		fmt.Fprint(w, `{}`)
+	}))
+	defer ts.Close()
+	// First verify that using a complete client results in success.
+	c := Client{
+		Key:          testKeyEC,
+		DirectoryURL: ts.URL,
+		dir:          &Directory{RegURL: ts.URL},
+	}
+	if _, err := c.Register(context.Background(), &Account{}, AcceptTOS); err != nil {
+		t.Fatalf("c.Register() = %v; want success with a complete test client", err)
+	}
+	c.Key = nil
+	if _, err := c.Register(context.Background(), &Account{}, AcceptTOS); err == nil {
+		t.Error("c.Register() from client without key succeeded, wanted error")
+	}
+}
+
 func TestUpdateReg(t *testing.T) {
 	const terms = "https://ca.tld/acme/terms"
 	contacts := []string{"mailto:admin@example.com"}
diff --git a/acme/jws.go b/acme/jws.go
index 76e3fda..04f509f 100644
--- a/acme/jws.go
+++ b/acme/jws.go
@@ -7,17 +7,31 @@
 import (
 	"crypto"
 	"crypto/ecdsa"
+	"crypto/hmac"
 	"crypto/rand"
 	"crypto/rsa"
 	"crypto/sha256"
+	"crypto/sha512"
 	_ "crypto/sha512" // need for EC keys
 	"encoding/asn1"
 	"encoding/base64"
 	"encoding/json"
+	"errors"
 	"fmt"
+	"hash"
 	"math/big"
 )
 
+// MACAlgorithm represents a JWS MAC signature algorithm.
+// See https://tools.ietf.org/html/rfc7518#section-3.1 for more details.
+type MACAlgorithm string
+
+const (
+	MACAlgorithmHS256 = MACAlgorithm("HS256")
+	MACAlgorithmHS384 = MACAlgorithm("HS384")
+	MACAlgorithmHS512 = MACAlgorithm("HS512")
+)
+
 // keyID is the account identity provided by a CA during registration.
 type keyID string
 
@@ -31,6 +45,14 @@
 // See https://tools.ietf.org/html/rfc8555#section-6.3 for more details.
 const noPayload = ""
 
+// jsonWebSignature can be easily serialized into a JWS following
+// https://tools.ietf.org/html/rfc7515#section-3.2.
+type jsonWebSignature struct {
+	Protected string `json:"protected"`
+	Payload   string `json:"payload"`
+	Sig       string `json:"signature"`
+}
+
 // jwsEncodeJSON signs claimset using provided key and a nonce.
 // The result is serialized in JSON format containing either kid or jwk
 // fields based on the provided keyID value.
@@ -71,12 +93,7 @@
 	if err != nil {
 		return nil, err
 	}
-
-	enc := struct {
-		Protected string `json:"protected"`
-		Payload   string `json:"payload"`
-		Sig       string `json:"signature"`
-	}{
+	enc := jsonWebSignature{
 		Protected: phead,
 		Payload:   payload,
 		Sig:       base64.RawURLEncoding.EncodeToString(sig),
@@ -84,6 +101,32 @@
 	return json.Marshal(&enc)
 }
 
+// jwsWithMAC creates and signs a JWS using the given key and algorithm.
+// "rawProtected" and "rawPayload" should not be base64-URL-encoded.
+func jwsWithMAC(key []byte, alg MACAlgorithm, rawProtected, rawPayload []byte) (*jsonWebSignature, error) {
+	if len(key) == 0 {
+		return nil, errors.New("acme: cannot sign JWS with an empty MAC key")
+	}
+	protected := base64.RawURLEncoding.EncodeToString(rawProtected)
+	payload := base64.RawURLEncoding.EncodeToString(rawPayload)
+
+	// Only HMACs are currently supported.
+	hmac, err := newHMAC(key, alg)
+	if err != nil {
+		return nil, err
+	}
+	if _, err := hmac.Write([]byte(protected + "." + payload)); err != nil {
+		return nil, err
+	}
+	mac := hmac.Sum(nil)
+
+	return &jsonWebSignature{
+		Protected: protected,
+		Payload:   payload,
+		Sig:       base64.RawURLEncoding.EncodeToString(mac),
+	}, nil
+}
+
 // jwkEncode encodes public part of an RSA or ECDSA key into a JWK.
 // The result is also suitable for creating a JWK thumbprint.
 // https://tools.ietf.org/html/rfc7517
@@ -175,6 +218,20 @@
 	return "", 0
 }
 
+// newHMAC returns an appropriate HMAC for the given MACAlgorithm.
+func newHMAC(key []byte, alg MACAlgorithm) (hash.Hash, error) {
+	switch alg {
+	case MACAlgorithmHS256:
+		return hmac.New(sha256.New, key), nil
+	case MACAlgorithmHS384:
+		return hmac.New(sha512.New384, key), nil
+	case MACAlgorithmHS512:
+		return hmac.New(sha512.New, key), nil
+	default:
+		return nil, fmt.Errorf("acme: unsupported MAC algorithm: %v", alg)
+	}
+}
+
 // JWKThumbprint creates a JWK thumbprint out of pub
 // as specified in https://tools.ietf.org/html/rfc7638.
 func JWKThumbprint(pub crypto.PublicKey) (string, error) {
diff --git a/acme/jws_test.go b/acme/jws_test.go
index 3507fe9..c6e3230 100644
--- a/acme/jws_test.go
+++ b/acme/jws_test.go
@@ -376,7 +376,7 @@
 			if err != nil {
 				t.Fatal(err)
 			}
-			var j struct{ Protected, Payload, Signature string }
+			var j jsonWebSignature
 			if err := json.Unmarshal(b, &j); err != nil {
 				t.Fatal(err)
 			}
@@ -386,8 +386,66 @@
 			if j.Payload != payload {
 				t.Errorf("j.Payload = %q\nwant %q", j.Payload, payload)
 			}
-			if j.Signature != tc.jwsig {
-				t.Errorf("j.Signature = %q\nwant %q", j.Signature, tc.jwsig)
+			if j.Sig != tc.jwsig {
+				t.Errorf("j.Sig = %q\nwant %q", j.Sig, tc.jwsig)
+			}
+		})
+	}
+}
+
+func TestJWSWithMAC(t *testing.T) {
+	// Example from RFC 7520 Section 4.4.3.
+	// https://tools.ietf.org/html/rfc7520#section-4.4.3
+	b64Key := "hJtXIZ2uSN5kbQfbtTNWbpdmhkV8FJG-Onbc6mxCcYg"
+	alg := MACAlgorithmHS256
+	rawProtected := []byte(`{"alg":"HS256","kid":"018c0ae5-4d9b-471b-bfd6-eef314bc7037"}`)
+	rawPayload := []byte("It\xe2\x80\x99s a dangerous business, Frodo, going out your " +
+		"door. You step onto the road, and if you don't keep your feet, " +
+		"there\xe2\x80\x99s no knowing where you might be swept off " +
+		"to.")
+	protected := "eyJhbGciOiJIUzI1NiIsImtpZCI6IjAxOGMwYWU1LTRkOWItNDcxYi1iZmQ2LW" +
+		"VlZjMxNGJjNzAzNyJ9"
+	payload := "SXTigJlzIGEgZGFuZ2Vyb3VzIGJ1c2luZXNzLCBGcm9kbywg" +
+		"Z29pbmcgb3V0IHlvdXIgZG9vci4gWW91IHN0ZXAgb250byB0aGUgcm9h" +
+		"ZCwgYW5kIGlmIHlvdSBkb24ndCBrZWVwIHlvdXIgZmVldCwgdGhlcmXi" +
+		"gJlzIG5vIGtub3dpbmcgd2hlcmUgeW91IG1pZ2h0IGJlIHN3ZXB0IG9m" +
+		"ZiB0by4"
+	sig := "s0h6KThzkfBBBkLspW1h84VsJZFTsPPqMDA7g1Md7p0"
+
+	key, err := base64.RawURLEncoding.DecodeString(b64Key)
+	if err != nil {
+		t.Fatalf("unable to decode key: %q", b64Key)
+	}
+	got, err := jwsWithMAC(key, alg, rawProtected, rawPayload)
+	if err != nil {
+		t.Fatalf("jwsWithMAC() = %q", err)
+	}
+	if got.Protected != protected {
+		t.Errorf("got.Protected = %q\nwant %q", got.Protected, protected)
+	}
+	if got.Payload != payload {
+		t.Errorf("got.Payload = %q\nwant %q", got.Payload, payload)
+	}
+	if got.Sig != sig {
+		t.Errorf("got.Signature = %q\nwant %q", got.Sig, sig)
+	}
+}
+
+func TestJWSWithMACError(t *testing.T) {
+	tt := []struct {
+		desc string
+		alg  MACAlgorithm
+		key  []byte
+	}{
+		{"Unknown Algorithm", MACAlgorithm("UNKNOWN-ALG"), []byte("hmac-key")},
+		{"Empty Key", MACAlgorithmHS256, nil},
+	}
+	for _, tc := range tt {
+		tc := tc
+		t.Run(string(tc.desc), func(t *testing.T) {
+			p := "{}"
+			if _, err := jwsWithMAC(tc.key, tc.alg, []byte(p), []byte(p)); err == nil {
+				t.Errorf("jwsWithMAC(%v, %v, %s, %s) = success; want err", tc.key, tc.alg, p, p)
 			}
 		})
 	}
@@ -467,3 +525,33 @@
 		t.Errorf("err = %q; want %q", err, ErrUnsupportedKey)
 	}
 }
+
+func TestNewHMAC(t *testing.T) {
+	tt := []struct {
+		alg      MACAlgorithm
+		wantSize int
+	}{
+		{MACAlgorithmHS256, 32},
+		{MACAlgorithmHS384, 48},
+		{MACAlgorithmHS512, 64},
+	}
+	for _, tc := range tt {
+		tc := tc
+		t.Run(string(tc.alg), func(t *testing.T) {
+			h, err := newHMAC([]byte("key"), tc.alg)
+			if err != nil {
+				t.Fatalf("newHMAC(%v) = %q", tc.alg, err)
+			}
+			gotSize := len(h.Sum(nil))
+			if gotSize != tc.wantSize {
+				t.Errorf("HMAC produced signature with unexpected length; got %d want %d", gotSize, tc.wantSize)
+			}
+		})
+	}
+}
+
+func TestNewHMACError(t *testing.T) {
+	if h, err := newHMAC([]byte("key"), MACAlgorithm("UNKNOWN-ALG")); err == nil {
+		t.Errorf("newHMAC(UNKNOWN-ALG) = %T, nil; want error", h)
+	}
+}
diff --git a/acme/rfc8555.go b/acme/rfc8555.go
index dfb57a6..ceb239d 100644
--- a/acme/rfc8555.go
+++ b/acme/rfc8555.go
@@ -5,6 +5,7 @@
 package acme
 
 import (
+	"bytes"
 	"context"
 	"crypto"
 	"encoding/base64"
@@ -37,22 +38,32 @@
 	return nil
 }
 
-// registerRFC is quivalent to c.Register but for CAs implementing RFC 8555.
+// registerRFC is equivalent to c.Register but for CAs implementing RFC 8555.
 // It expects c.Discover to have already been called.
-// TODO: Implement externalAccountBinding.
 func (c *Client) registerRFC(ctx context.Context, acct *Account, prompt func(tosURL string) bool) (*Account, error) {
 	c.cacheMu.Lock() // guard c.kid access
 	defer c.cacheMu.Unlock()
 
 	req := struct {
-		TermsAgreed bool     `json:"termsOfServiceAgreed,omitempty"`
-		Contact     []string `json:"contact,omitempty"`
+		TermsAgreed            bool              `json:"termsOfServiceAgreed,omitempty"`
+		Contact                []string          `json:"contact,omitempty"`
+		ExternalAccountBinding *jsonWebSignature `json:"externalAccountBinding,omitempty"`
 	}{
 		Contact: acct.Contact,
 	}
 	if c.dir.Terms != "" {
 		req.TermsAgreed = prompt(c.dir.Terms)
 	}
+
+	// set 'externalAccountBinding' field if requested
+	if acct.ExternalAccountBinding != nil {
+		eabJWS, err := c.encodeExternalAccountBinding(acct.ExternalAccountBinding)
+		if err != nil {
+			return nil, fmt.Errorf("acme: failed to encode external account binding: %v", err)
+		}
+		req.ExternalAccountBinding = eabJWS
+	}
+
 	res, err := c.post(ctx, c.Key, c.dir.RegURL, req, wantStatus(
 		http.StatusOK,      // account with this key already registered
 		http.StatusCreated, // new account created
@@ -75,7 +86,19 @@
 	return a, nil
 }
 
-// updateGegRFC is equivalent to c.UpdateReg but for CAs implementing RFC 8555.
+// encodeExternalAccountBinding will encode an external account binding stanza
+// as described in https://tools.ietf.org/html/rfc8555#section-7.3.4.
+func (c *Client) encodeExternalAccountBinding(eab *ExternalAccountBinding) (*jsonWebSignature, error) {
+	jwk, err := jwkEncode(c.Key.Public())
+	if err != nil {
+		return nil, err
+	}
+	var rProtected bytes.Buffer
+	fmt.Fprintf(&rProtected, `{"alg":%q,"kid":%q,"url":%q}`, eab.Algorithm, eab.KID, c.dir.RegURL)
+	return jwsWithMAC(eab.Key, eab.Algorithm, rProtected.Bytes(), []byte(jwk))
+}
+
+// updateRegRFC is equivalent to c.UpdateReg but for CAs implementing RFC 8555.
 // It expects c.Discover to have already been called.
 func (c *Client) updateRegRFC(ctx context.Context, a *Account) (*Account, error) {
 	url := string(c.accountKID(ctx))
diff --git a/acme/rfc8555_test.go b/acme/rfc8555_test.go
index 7e5e446..b985b3e 100644
--- a/acme/rfc8555_test.go
+++ b/acme/rfc8555_test.go
@@ -7,9 +7,12 @@
 import (
 	"bytes"
 	"context"
+	"crypto/hmac"
 	"crypto/rand"
+	"crypto/sha256"
 	"crypto/x509"
 	"crypto/x509/pkix"
+	"encoding/base64"
 	"encoding/json"
 	"encoding/pem"
 	"fmt"
@@ -346,6 +349,145 @@
 	}
 }
 
+func TestRFC_RegisterExternalAccountBinding(t *testing.T) {
+	eab := &ExternalAccountBinding{
+		KID:       "kid-1",
+		Key:       []byte("secret"),
+		Algorithm: MACAlgorithmHS256,
+	}
+
+	type protected struct {
+		Algorithm string `json:"alg"`
+		KID       string `json:"kid"`
+		URL       string `json:"url"`
+	}
+	const email = "mailto:user@example.org"
+
+	s := newACMEServer()
+	s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Location", s.url("/accounts/1"))
+		if r.Method != "POST" {
+			t.Errorf("r.Method = %q; want POST", r.Method)
+		}
+
+		var j struct {
+			Protected              string
+			Contact                []string
+			TermsOfServiceAgreed   bool
+			ExternalaccountBinding struct {
+				Protected string
+				Payload   string
+				Signature string
+			}
+		}
+		decodeJWSRequest(t, &j, r.Body)
+		protData, err := base64.RawURLEncoding.DecodeString(j.ExternalaccountBinding.Protected)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		var prot protected
+		err = json.Unmarshal(protData, &prot)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		if !reflect.DeepEqual(j.Contact, []string{email}) {
+			t.Errorf("j.Contact = %v; want %v", j.Contact, []string{email})
+		}
+		if !j.TermsOfServiceAgreed {
+			t.Error("j.TermsOfServiceAgreed = false; want true")
+		}
+
+		// Ensure same KID.
+		if prot.KID != eab.KID {
+			t.Errorf("j.ExternalAccountBinding.KID = %s; want %s", prot.KID, eab.KID)
+		}
+		// Ensure same Algorithm.
+		if prot.Algorithm != string(eab.Algorithm) {
+			t.Errorf("j.ExternalAccountBinding.Alg = %s; want %s",
+				prot.Algorithm, eab.Algorithm)
+		}
+
+		// Ensure same URL as outer JWS.
+		url := fmt.Sprintf("http://%s/acme/new-account", r.Host)
+		if prot.URL != url {
+			t.Errorf("j.ExternalAccountBinding.URL = %s; want %s",
+				prot.URL, url)
+		}
+
+		// Ensure payload is base64URL encoded string of JWK in outer JWS
+		jwk, err := jwkEncode(testKeyEC.Public())
+		if err != nil {
+			t.Fatal(err)
+		}
+		decodedPayload, err := base64.RawURLEncoding.DecodeString(j.ExternalaccountBinding.Payload)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if jwk != string(decodedPayload) {
+			t.Errorf("j.ExternalAccountBinding.Payload = %s; want %s", decodedPayload, jwk)
+		}
+
+		// Check signature on inner external account binding JWS
+		hmac := hmac.New(sha256.New, []byte("secret"))
+		_, err = hmac.Write([]byte(j.ExternalaccountBinding.Protected + "." + j.ExternalaccountBinding.Payload))
+		if err != nil {
+			t.Fatal(err)
+		}
+		mac := hmac.Sum(nil)
+		encodedMAC := base64.RawURLEncoding.EncodeToString(mac)
+
+		if !bytes.Equal([]byte(encodedMAC), []byte(j.ExternalaccountBinding.Signature)) {
+			t.Errorf("j.ExternalAccountBinding.Signature = %v; want %v",
+				[]byte(j.ExternalaccountBinding.Signature), encodedMAC)
+		}
+
+		w.Header().Set("Location", s.url("/accounts/1"))
+		w.WriteHeader(http.StatusCreated)
+		b, _ := json.Marshal([]string{email})
+		fmt.Fprintf(w, `{"status":"valid","orders":"%s","contact":%s}`, s.url("/accounts/1/orders"), b)
+	})
+	s.start()
+	defer s.close()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+	cl := &Client{
+		Key:          testKeyEC,
+		DirectoryURL: s.url("/"),
+	}
+
+	var didPrompt bool
+	a := &Account{Contact: []string{email}, ExternalAccountBinding: eab}
+	acct, err := cl.Register(ctx, a, func(tos string) bool {
+		didPrompt = true
+		terms := s.url("/terms")
+		if tos != terms {
+			t.Errorf("tos = %q; want %q", tos, terms)
+		}
+		return true
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	okAccount := &Account{
+		URI:       s.url("/accounts/1"),
+		Status:    StatusValid,
+		Contact:   []string{email},
+		OrdersURL: s.url("/accounts/1/orders"),
+	}
+	if !reflect.DeepEqual(acct, okAccount) {
+		t.Errorf("acct = %+v; want %+v", acct, okAccount)
+	}
+	if !didPrompt {
+		t.Error("tos prompt wasn't called")
+	}
+	if v := cl.accountKID(ctx); v != keyID(okAccount.URI) {
+		t.Errorf("account kid = %q; want %q", v, okAccount.URI)
+	}
+}
+
 func TestRFC_RegisterExisting(t *testing.T) {
 	s := newACMEServer()
 	s.handle("/acme/new-account", func(w http.ResponseWriter, r *http.Request) {
diff --git a/acme/types.go b/acme/types.go
index e959caf..4d89fed 100644
--- a/acme/types.go
+++ b/acme/types.go
@@ -199,6 +199,31 @@
 	//
 	// It is non-RFC 8555 compliant and is obsoleted by OrdersURL.
 	Certificates string
+
+	// ExternalAccountBinding represents an arbitrary binding to an account of
+	// the CA which the ACME server is tied to.
+	// See https://tools.ietf.org/html/rfc8555#section-7.3.4 for more details.
+	ExternalAccountBinding *ExternalAccountBinding
+}
+
+// ExternalAccountBinding contains the data needed to form a request with
+// an external account binding.
+// See https://tools.ietf.org/html/rfc8555#section-7.3.4 for more details.
+type ExternalAccountBinding struct {
+	// KID is the Key ID of the symmetric MAC key that the CA provides to
+	// identify an external account from ACME.
+	KID string
+
+	// Key is the bytes of the symmetric key that the CA provides to identify
+	// the account. Key must correspond to the KID.
+	Key []byte
+
+	// Algorithm used to sign the JWS.
+	Algorithm MACAlgorithm
+}
+
+func (e *ExternalAccountBinding) String() string {
+	return fmt.Sprintf("&{KID: %q, Key: redacted, Algorithm: %v}", e.KID, e.Algorithm)
 }
 
 // Directory is ACME server discovery data.
diff --git a/acme/types_test.go b/acme/types_test.go
index 40ef20b..25fa8b2 100644
--- a/acme/types_test.go
+++ b/acme/types_test.go
@@ -11,6 +11,19 @@
 	"time"
 )
 
+func TestExternalAccountBindingString(t *testing.T) {
+	eab := ExternalAccountBinding{
+		KID:       "kid",
+		Key:       []byte("key"),
+		Algorithm: MACAlgorithmHS256,
+	}
+	got := eab.String()
+	want := `&{KID: "kid", Key: redacted, Algorithm: HS256}`
+	if got != want {
+		t.Errorf("eab.String() = %q, want: %q", got, want)
+	}
+}
+
 func TestRateLimit(t *testing.T) {
 	now := time.Date(2017, 04, 27, 10, 0, 0, 0, time.UTC)
 	f := timeNow