diff --git a/acme/http.go b/acme/http.go index 600d579..c6535fd 100644 --- a/acme/http.go +++ b/acme/http.go @@ -200,7 +200,7 @@ func (c *Client) postNoRetry(ctx context.Context, key crypto.Signer, url string, if err != nil { return nil, nil, err } - b, err := jwsEncodeJSON(body, key, nonce) + b, err := jwsEncodeJSON(body, key, noKeyID, nonce, url) if err != nil { return nil, nil, err } diff --git a/acme/jws.go b/acme/jws.go index 1093b50..f26bd5b 100644 --- a/acme/jws.go +++ b/acme/jws.go @@ -17,19 +17,38 @@ import ( "math/big" ) +// keyID is the account identity provided by a CA during registration. +type keyID string + +// noKeyID indicates that jwsEncodeJSON should compute and use JWK instead of a KID. +// See jwsEncodeJSON for details. +const noKeyID = keyID("") + // jwsEncodeJSON signs claimset using provided key and a nonce. -// The result is serialized in JSON format. +// The result is serialized in JSON format containing either kid or jwk +// fields based on the provided keyID value. +// +// If kid is non-empty, its quoted value is inserted in the protected head +// as "kid" field value. Otherwise, JWK is computed using jwkEncode and inserted +// as "jwk" field value. +// // See https://tools.ietf.org/html/rfc7515#section-7. -func jwsEncodeJSON(claimset interface{}, key crypto.Signer, nonce string) ([]byte, error) { - jwk, err := jwkEncode(key.Public()) - if err != nil { - return nil, err - } +func jwsEncodeJSON(claimset interface{}, key crypto.Signer, kid keyID, nonce, url string) ([]byte, error) { alg, sha := jwsHasher(key.Public()) if alg == "" || !sha.Available() { return nil, ErrUnsupportedKey } - phead := fmt.Sprintf(`{"alg":%q,"jwk":%s,"nonce":%q}`, alg, jwk, nonce) + var phead string + switch kid { + case noKeyID: + jwk, err := jwkEncode(key.Public()) + if err != nil { + return nil, err + } + phead = fmt.Sprintf(`{"alg":%q,"jwk":%s,"nonce":%q,"url":%q}`, alg, jwk, nonce, url) + default: + phead = fmt.Sprintf(`{"alg":%q,"kid":%q,"nonce":%q,"url":%q}`, alg, kid, nonce, url) + } phead = base64.RawURLEncoding.EncodeToString([]byte(phead)) cs, err := json.Marshal(claimset) if err != nil { diff --git a/acme/jws_test.go b/acme/jws_test.go index ee30b1e..560ab96 100644 --- a/acme/jws_test.go +++ b/acme/jws_test.go @@ -9,6 +9,7 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rsa" + "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" @@ -19,7 +20,18 @@ import ( "testing" ) +// The following shell command alias is used in the comments +// throughout this file: +// alias b64raw="base64 -w0 | tr -d '=' | tr '/+' '_-'" + const ( + // Modulus in raw base64: + // 4xgZ3eRPkwoRvy7qeRUbmMDe0V-xH9eWLdu0iheeLlrmD2mqWXfP9IeSKApbn34 + // g8TuAS9g5zhq8ELQ3kmjr-KV86GAMgI6VAcGlq3QrzpTCf_30Ab7-zawrfRaFON + // a1HwEzPY1KHnGVkxJc85gNkwYI9SY2RHXtvln3zs5wITNrdosqEXeaIkVYBEhbh + // Nu54pp3kxo6TuWLi9e6pXeWetEwmlBwtWZlPoib2j3TxLBksKZfoyFyek380mHg + // JAumQ_I2fjj98_97mk3ihOY4AgVdCDj1z_GCoZkG5Rq7nbCGyosyKWyDX00Zs-n + // NqVhoLeIvXC4nnWdJMZ6rogxyQQ testKeyPEM = ` -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEA4xgZ3eRPkwoRvy7qeRUbmMDe0V+xH9eWLdu0iheeLlrmD2mq @@ -82,7 +94,7 @@ GGnm6rb+NnWR9DIopM0nKNkToWoF/hzopxu4Ae/GsQ== ` // 1. openssl ec -in key.pem -noout -text // 2. remove first byte, 04 (the header); the rest is X and Y - // 3. convert each with: echo | xxd -r -p | base64 -w 100 | tr -d '=' | tr '/+' '_-' + // 3. convert each with: echo | xxd -r -p | b64raw testKeyECPubX = "5lhEug5xK4xBDZ2nAbaxLtaLiv85bxJ7ePd1dkO23HQ" testKeyECPubY = "4aiK72sBeUAGkv0TaLsmwokYUYyNxGsS5EMIKwsNIKk" testKeyEC384PubX = "MyrY_jLNLx6E1-Xc79_y-WDFzlriOVCkYyYoKWoWAqlw9gQNY9BP9sbeb5T3_oJt" @@ -91,7 +103,7 @@ GGnm6rb+NnWR9DIopM0nKNkToWoF/hzopxu4Ae/GsQ== testKeyEC512PubY = "AXbmSeogEiDlDwz0Gc670YYByFzC3c7tEMeap7CckkOtuN0Yaebqtv42dZH0MiikzSco2ROhagX-HOinG7gB78ax" // echo -n '{"crv":"P-256","kty":"EC","x":"","y":""}' | \ - // openssl dgst -binary -sha256 | base64 | tr -d '=' | tr '/+' '_-' + // openssl dgst -binary -sha256 | b64raw testKeyECThumbprint = "zedj-Bd1Zshp8KLePv2MB-lJ_Hagp7wAwdkA0NUTniU" ) @@ -140,7 +152,7 @@ func TestJWSEncodeJSON(t *testing.T) { // 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"} + // {"alg":"RS256","jwk":{"e":"AQAB","kty":"RSA","n":"..."},"nonce":"nonce","url":"url"} protected = "eyJhbGciOiJSUzI1NiIsImp3ayI6eyJlIjoiQVFBQiIsImt0eSI6" + "IlJTQSIsIm4iOiI0eGdaM2VSUGt3b1J2eTdxZVJVYm1NRGUwVi14" + "SDllV0xkdTBpaGVlTGxybUQybXFXWGZQOUllU0tBcGJuMzRnOFR1" + @@ -151,19 +163,20 @@ func TestJWSEncodeJSON(t *testing.T) { "bFBvaWIyajNUeExCa3NLWmZveUZ5ZWszODBtSGdKQXVtUV9JMmZq" + "ajk4Xzk3bWszaWhPWTRBZ1ZkQ0RqMXpfR0NvWmtHNVJxN25iQ0d5" + "b3N5S1d5RFgwMFpzLW5OcVZob0xlSXZYQzRubldkSk1aNnJvZ3h5" + - "UVEifSwibm9uY2UiOiJub25jZSJ9" + "UVEifSwibm9uY2UiOiJub25jZSIsInVybCI6InVybCJ9" // {"Msg":"Hello JWS"} - payload = "eyJNc2ciOiJIZWxsbyBKV1MifQ" - signature = "eAGUikStX_UxyiFhxSLMyuyBcIB80GeBkFROCpap2sW3EmkU_ggF" + - "knaQzxrTfItICSAXsCLIquZ5BbrSWA_4vdEYrwWtdUj7NqFKjHRa" + - "zpLHcoR7r1rEHvkoP1xj49lS5fc3Wjjq8JUhffkhGbWZ8ZVkgPdC" + - "4tMBWiQDoth-x8jELP_3LYOB_ScUXi2mETBawLgOT2K8rA0Vbbmx" + - "hWNlOWuUf-8hL5YX4IOEwsS8JK_TrTq5Zc9My0zHJmaieqDV0UlP" + - "k0onFjPFkGm7MrPSgd0MqRG-4vSAg2O4hDo7rKv4n8POjjXlNQvM" + - "9IPLr8qZ7usYBKhEGwX3yq_eicAwBw" + payload = "eyJNc2ciOiJIZWxsbyBKV1MifQ" + // printf '.' | openssl dgst -binary -sha256 -sign testKey | b64raw + signature = "YFyl_xz1E7TR-3E1bIuASTr424EgCvBHjt25WUFC2VaDjXYV0Rj_" + + "Hd3dJ_2IRqBrXDZZ2n4ZeA_4mm3QFwmwyeDwe2sWElhb82lCZ8iX" + + "uFnjeOmSOjx-nWwPa5ibCXzLq13zZ-OBV1Z4oN_TuailQeRoSfA3" + + "nO8gG52mv1x2OMQ5MAFtt8jcngBLzts4AyhI6mBJ2w7Yaj3ZCriq" + + "DWA3GLFvvHdW1Ba9Z01wtGT2CuZI7DUk_6Qj1b3BkBGcoKur5C9i" + + "bUJtCkABwBMvBQNyD3MmXsrRFRTgvVlyU_yMaucYm7nmzEr_2PaQ" + + "50rFt_9qOfJ4sfbLtG1Wwae57BQx1g" ) - b, err := jwsEncodeJSON(claims, testKey, "nonce") + b, err := jwsEncodeJSON(claims, testKey, noKeyID, "nonce", "url") if err != nil { t.Fatal(err) } @@ -182,6 +195,46 @@ func TestJWSEncodeJSON(t *testing.T) { } } +func TestJWSEncodeKID(t *testing.T) { + kid := keyID("https://example.org/account/1") + claims := struct{ Msg string }{"Hello JWS"} + // JWS signed with testKeyEC + const ( + // {"alg":"ES256","kid":"https://example.org/account/1","nonce":"nonce","url":"url"} + protected = "eyJhbGciOiJFUzI1NiIsImtpZCI6Imh0dHBzOi8vZXhhbXBsZS5" + + "vcmcvYWNjb3VudC8xIiwibm9uY2UiOiJub25jZSIsInVybCI6InVybCJ9" + // {"Msg":"Hello JWS"} + payload = "eyJNc2ciOiJIZWxsbyBKV1MifQ" + ) + + b, err := jwsEncodeJSON(claims, testKeyEC, kid, "nonce", "url") + 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) + } + + sig, err := base64.RawURLEncoding.DecodeString(jws.Signature) + if err != nil { + t.Fatalf("jws.Signature: %v", err) + } + r, s := big.NewInt(0), big.NewInt(0) + r.SetBytes(sig[:len(sig)/2]) + s.SetBytes(sig[len(sig)/2:]) + h := sha256.Sum256([]byte(protected + "." + payload)) + if !ecdsa.Verify(testKeyEC.Public().(*ecdsa.PublicKey), h[:], r, s) { + t.Error("invalid signature") + } +} + func TestJWSEncodeJSONEC(t *testing.T) { tt := []struct { key *ecdsa.PrivateKey @@ -194,7 +247,7 @@ func TestJWSEncodeJSONEC(t *testing.T) { } for i, test := range tt { claims := struct{ Msg string }{"Hello JWS"} - b, err := jwsEncodeJSON(claims, test.key, "nonce") + b, err := jwsEncodeJSON(claims, test.key, noKeyID, "nonce", "url") if err != nil { t.Errorf("%d: %v", i, err) continue @@ -212,6 +265,8 @@ func TestJWSEncodeJSONEC(t *testing.T) { var head struct { Alg string Nonce string + URL string `json:"url"` + KID string `json:"kid"` JWK struct { Crv string Kty string @@ -228,6 +283,13 @@ func TestJWSEncodeJSONEC(t *testing.T) { if head.Nonce != "nonce" { t.Errorf("%d: head.Nonce = %q; want nonce", i, head.Nonce) } + if head.URL != "url" { + t.Errorf("%d: head.URL = %q; want 'url'", i, head.URL) + } + if head.KID != "" { + // We used noKeyID in jwsEncodeJSON: expect no kid value. + t.Errorf("%d: head.KID = %q; want empty", i, head.KID) + } if head.JWK.Crv != test.crv { t.Errorf("%d: head.JWK.Crv = %q; want %q", i, head.JWK.Crv, test.crv) } @@ -256,18 +318,19 @@ func (s *customTestSigner) Sign(io.Reader, []byte, crypto.SignerOpts) ([]byte, e func TestJWSEncodeJSONCustom(t *testing.T) { claims := struct{ Msg string }{"hello"} const ( - // printf '{"Msg":"hello"}' | base64 | tr -d '=' | tr '/+' '_-' + // printf '{"Msg":"hello"}' | b64raw payload = "eyJNc2ciOiJoZWxsbyJ9" - // printf 'testsig' | base64 | tr -d '=' + // printf 'testsig' | b64raw testsig = "dGVzdHNpZw" - // printf '{"alg":"ES256","jwk":{"crv":"P-256","kty":"EC","x":,"y":,"nonce":"nonce"}' | \ - // base64 | tr -d '=' | tr '/+' '_-' - es256phead = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6IjVsaEV1" + - "ZzV4SzR4QkRaMm5BYmF4THRhTGl2ODVieEo3ZVBkMWRrTzIzSFEiLCJ5IjoiNGFpSzcyc0JlVUFH" + - "a3YwVGFMc213b2tZVVl5TnhHc1M1RU1JS3dzTklLayJ9LCJub25jZSI6Im5vbmNlIn0" + // printf '{"alg":"ES256","jwk":{"crv":"P-256","kty":"EC","x":,"y":},"nonce":"nonce","url":"url"}' | b64raw + es256phead = "eyJhbGciOiJFUzI1NiIsImp3ayI6eyJjcnYiOiJQLTI1NiIsImt0" + + "eSI6IkVDIiwieCI6IjVsaEV1ZzV4SzR4QkRaMm5BYmF4THRhTGl2" + + "ODVieEo3ZVBkMWRrTzIzSFEiLCJ5IjoiNGFpSzcyc0JlVUFHa3Yw" + + "VGFMc213b2tZVVl5TnhHc1M1RU1JS3dzTklLayJ9LCJub25jZSI6" + + "Im5vbmNlIiwidXJsIjoidXJsIn0" - // {"alg":"RS256","jwk":{"e":"AQAB","kty":"RSA","n":"..."},"nonce":"nonce"} + // {"alg":"RS256","jwk":{"e":"AQAB","kty":"RSA","n":"..."},"nonce":"nonce","url":"url"} rs256phead = "eyJhbGciOiJSUzI1NiIsImp3ayI6eyJlIjoiQVFBQiIsImt0eSI6" + "IlJTQSIsIm4iOiI0eGdaM2VSUGt3b1J2eTdxZVJVYm1NRGUwVi14" + "SDllV0xkdTBpaGVlTGxybUQybXFXWGZQOUllU0tBcGJuMzRnOFR1" + @@ -278,15 +341,15 @@ func TestJWSEncodeJSONCustom(t *testing.T) { "bFBvaWIyajNUeExCa3NLWmZveUZ5ZWszODBtSGdKQXVtUV9JMmZq" + "ajk4Xzk3bWszaWhPWTRBZ1ZkQ0RqMXpfR0NvWmtHNVJxN25iQ0d5" + "b3N5S1d5RFgwMFpzLW5OcVZob0xlSXZYQzRubldkSk1aNnJvZ3h5" + - "UVEifSwibm9uY2UiOiJub25jZSJ9" + "UVEifSwibm9uY2UiOiJub25jZSIsInVybCI6InVybCJ9" ) tt := []struct { alg, phead string pub crypto.PublicKey }{ - {"RS256", rs256phead, testKey.Public()}, {"ES256", es256phead, testKeyEC.Public()}, + {"RS256", rs256phead, testKey.Public()}, } for _, tc := range tt { tc := tc @@ -295,7 +358,7 @@ func TestJWSEncodeJSONCustom(t *testing.T) { sig: []byte("testsig"), pub: tc.pub, } - b, err := jwsEncodeJSON(claims, signer, "nonce") + b, err := jwsEncodeJSON(claims, signer, noKeyID, "nonce", "url") if err != nil { t.Fatal(err) } @@ -352,10 +415,8 @@ func TestJWKThumbprintRSA(t *testing.T) { func TestJWKThumbprintEC(t *testing.T) { // Key example from RFC 7520 // expected was computed with - // echo -n '{"crv":"P-521","kty":"EC","x":"","y":""}' | \ - // openssl dgst -binary -sha256 | \ - // base64 | \ - // tr -d '=' | tr '/+' '_-' + // printf '{"crv":"P-521","kty":"EC","x":"","y":""}' | \ + // openssl dgst -binary -sha256 | b64raw const ( base64X = "AHKZLLOsCOzz5cY97ewNUajB957y-C-U88c3v13nmGZx6sYl_oJXu9A5RkT" + "KqjqvjyekWF-7ytDyRXYgCF5cj0Kt"