Skip to content

Commit

Permalink
acme: add external account binding support
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
2 people authored and rolandshoemaker committed Dec 17, 2020
1 parent 8b5274c commit 9d13527
Show file tree
Hide file tree
Showing 8 changed files with 391 additions and 14 deletions.
4 changes: 4 additions & 0 deletions acme/acme.go
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,10 @@ func AcceptTOS(tosURL string) bool { return true }
// 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
Expand Down
25 changes: 25 additions & 0 deletions acme/acme_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,31 @@ func TestRegister(t *testing.T) {
}
}

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"}
Expand Down
69 changes: 63 additions & 6 deletions acme/jws.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,31 @@ package acme
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

Expand All @@ -31,6 +45,14 @@ const noKeyID = keyID("")
// 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.
Expand Down Expand Up @@ -71,19 +93,40 @@ func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid keyID, nonce, ur
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),
}
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
Expand Down Expand Up @@ -175,6 +218,20 @@ func jwsHasher(pub crypto.PublicKey) (string, crypto.Hash) {
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) {
Expand Down
94 changes: 91 additions & 3 deletions acme/jws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,7 +376,7 @@ func TestJWSEncodeJSONCustom(t *testing.T) {
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)
}
Expand All @@ -386,8 +386,66 @@ func TestJWSEncodeJSONCustom(t *testing.T) {
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)
}
})
}
Expand Down Expand Up @@ -467,3 +525,33 @@ func TestJWKThumbprintErrUnsupportedKey(t *testing.T) {
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)
}
}
33 changes: 28 additions & 5 deletions acme/rfc8555.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package acme

import (
"bytes"
"context"
"crypto"
"encoding/base64"
Expand Down Expand Up @@ -37,22 +38,32 @@ func (c *Client) DeactivateReg(ctx context.Context) error {
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
Expand All @@ -75,7 +86,19 @@ func (c *Client) registerRFC(ctx context.Context, acct *Account, prompt func(tos
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))
Expand Down
Loading

0 comments on commit 9d13527

Please sign in to comment.