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 @@ 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 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 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"} 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 @@ 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 @@ -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. @@ -71,12 +93,7 @@ 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), @@ -84,6 +101,32 @@ func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid keyID, nonce, ur 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 @@ 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) { 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 @@ 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) } @@ -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) } }) } @@ -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) + } +} 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 @@ 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 @@ -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)) 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 @@ package acme 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_Register(t *testing.T) { } } +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 @@ type Account struct { // // 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 @@ import ( "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