package wfe2 import ( "context" "crypto" "crypto/dsa" "crypto/ecdsa" "crypto/elliptic" "crypto/rsa" "errors" "fmt" "net/http" "slices" "strings" "testing" "github.com/prometheus/client_golang/prometheus" "github.com/letsencrypt/boulder/core" corepb "github.com/letsencrypt/boulder/core/proto" berrors "github.com/letsencrypt/boulder/errors" "github.com/letsencrypt/boulder/goodkey" bgrpc "github.com/letsencrypt/boulder/grpc" "github.com/letsencrypt/boulder/grpc/noncebalancer" noncepb "github.com/letsencrypt/boulder/nonce/proto" sapb "github.com/letsencrypt/boulder/sa/proto" "github.com/letsencrypt/boulder/test" "github.com/letsencrypt/boulder/web" "github.com/go-jose/go-jose/v4" "google.golang.org/grpc" ) // sigAlgForKey uses `signatureAlgorithmForKey` but fails immediately using the // testing object if the sig alg is unknown. func sigAlgForKey(t *testing.T, key interface{}) jose.SignatureAlgorithm { var sigAlg jose.SignatureAlgorithm var err error // Gracefully handle the case where a non-pointer public key is given where // sigAlgorithmForKey always wants a pointer. It may be tempting to try and do // `sigAlgorithmForKey(&jose.JSONWebKey{Key: &key})` without a type switch but this produces // `*interface {}` and not the desired `*rsa.PublicKey` or `*ecdsa.PublicKey`. switch k := key.(type) { case rsa.PublicKey: sigAlg, err = sigAlgorithmForKey(&jose.JSONWebKey{Key: &k}) case ecdsa.PublicKey: sigAlg, err = sigAlgorithmForKey(&jose.JSONWebKey{Key: &k}) default: sigAlg, err = sigAlgorithmForKey(&jose.JSONWebKey{Key: k}) } test.Assert(t, err == nil, fmt.Sprintf("Error getting signature algorithm for key %#v", key)) return sigAlg } // keyAlgForKey returns a JWK key algorithm based on the provided private key. // Only ECDSA and RSA private keys are supported. func keyAlgForKey(t *testing.T, key interface{}) string { switch key.(type) { case *rsa.PrivateKey, rsa.PrivateKey: return "RSA" case *ecdsa.PrivateKey, ecdsa.PrivateKey: return "ECDSA" } t.Fatalf("Can't figure out keyAlgForKey: %#v", key) return "" } // pubKeyForKey returns the public key of an RSA/ECDSA private key provided as // argument. func pubKeyForKey(t *testing.T, privKey interface{}) interface{} { switch k := privKey.(type) { case *rsa.PrivateKey: return k.PublicKey case *ecdsa.PrivateKey: return k.PublicKey } t.Fatalf("Unable to get public key for private key %#v", privKey) return nil } // requestSigner offers methods to sign requests that will be accepted by a // specific WFE in unittests. It is only valid for the lifetime of a single // unittest. type requestSigner struct { t *testing.T nonceService jose.NonceSource } // embeddedJWK creates a JWS for a given request body with an embedded JWK // corresponding to the private key provided. The URL and nonce extra headers // are set based on the additional arguments. A computed JWS, the corresponding // embedded JWK and the JWS in serialized string form are returned. func (rs requestSigner) embeddedJWK( privateKey interface{}, url string, req string) (*jose.JSONWebSignature, *jose.JSONWebKey, string) { // if no key is provided default to test1KeyPrivatePEM var publicKey interface{} if privateKey == nil { signer := loadKey(rs.t, []byte(test1KeyPrivatePEM)) privateKey = signer publicKey = signer.Public() } else { publicKey = pubKeyForKey(rs.t, privateKey) } signerKey := jose.SigningKey{ Key: privateKey, Algorithm: sigAlgForKey(rs.t, publicKey), } opts := &jose.SignerOptions{ NonceSource: rs.nonceService, EmbedJWK: true, } if url != "" { opts.ExtraHeaders = map[jose.HeaderKey]interface{}{ "url": url, } } signer, err := jose.NewSigner(signerKey, opts) test.AssertNotError(rs.t, err, "Failed to make signer") jws, err := signer.Sign([]byte(req)) test.AssertNotError(rs.t, err, "Failed to sign req") body := jws.FullSerialize() parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) test.AssertNotError(rs.t, err, "Failed to parse generated JWS") return parsedJWS, parsedJWS.Signatures[0].Header.JSONWebKey, body } // signRequestKeyID creates a JWS for a given request body with key ID specified // based on the ID number provided. The URL and nonce extra headers // are set based on the additional arguments. A computed JWS, the corresponding // embedded JWK and the JWS in serialized string form are returned. func (rs requestSigner) byKeyID( keyID int64, privateKey interface{}, url string, req string) (*jose.JSONWebSignature, *jose.JSONWebKey, string) { // if no key is provided default to test1KeyPrivatePEM if privateKey == nil { privateKey = loadKey(rs.t, []byte(test1KeyPrivatePEM)) } jwk := &jose.JSONWebKey{ Key: privateKey, Algorithm: keyAlgForKey(rs.t, privateKey), KeyID: fmt.Sprintf("http://localhost/acme/acct/%d", keyID), } signerKey := jose.SigningKey{ Key: jwk, Algorithm: jose.RS256, } opts := &jose.SignerOptions{ NonceSource: rs.nonceService, ExtraHeaders: map[jose.HeaderKey]interface{}{ "url": url, }, } signer, err := jose.NewSigner(signerKey, opts) test.AssertNotError(rs.t, err, "Failed to make signer") jws, err := signer.Sign([]byte(req)) test.AssertNotError(rs.t, err, "Failed to sign req") body := jws.FullSerialize() parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) test.AssertNotError(rs.t, err, "Failed to parse generated JWS") return parsedJWS, jwk, body } // missingNonce returns an otherwise well-signed request that is missing its // nonce. func (rs requestSigner) missingNonce() *jose.JSONWebSignature { privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) jwk := &jose.JSONWebKey{ Key: privateKey, Algorithm: keyAlgForKey(rs.t, privateKey), KeyID: "http://localhost/acme/acct/1", } signerKey := jose.SigningKey{ Key: jwk, Algorithm: jose.RS256, } opts := &jose.SignerOptions{ ExtraHeaders: map[jose.HeaderKey]interface{}{ "url": "https://example.com/acme/foo", }, } signer, err := jose.NewSigner(signerKey, opts) test.AssertNotError(rs.t, err, "Failed to make signer") jws, err := signer.Sign([]byte("")) test.AssertNotError(rs.t, err, "Failed to sign req") return jws } // invalidNonce returns an otherwise well-signed request with an invalid nonce. func (rs requestSigner) invalidNonce() *jose.JSONWebSignature { privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) jwk := &jose.JSONWebKey{ Key: privateKey, Algorithm: keyAlgForKey(rs.t, privateKey), KeyID: "http://localhost/acme/acct/1", } signerKey := jose.SigningKey{ Key: jwk, Algorithm: jose.RS256, } opts := &jose.SignerOptions{ NonceSource: badNonceProvider{}, ExtraHeaders: map[jose.HeaderKey]interface{}{ "url": "https://example.com/acme/foo", }, } signer, err := jose.NewSigner(signerKey, opts) test.AssertNotError(rs.t, err, "Failed to make signer") jws, err := signer.Sign([]byte("")) test.AssertNotError(rs.t, err, "Failed to sign req") body := jws.FullSerialize() parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) test.AssertNotError(rs.t, err, "Failed to parse generated JWS") return parsedJWS } // malformedNonce returns an otherwise well-signed request with a malformed // nonce. func (rs requestSigner) malformedNonce() *jose.JSONWebSignature { privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) jwk := &jose.JSONWebKey{ Key: privateKey, Algorithm: keyAlgForKey(rs.t, privateKey), KeyID: "http://localhost/acme/acct/1", } signerKey := jose.SigningKey{ Key: jwk, Algorithm: jose.RS256, } opts := &jose.SignerOptions{ NonceSource: badNonceProvider{malformed: true}, ExtraHeaders: map[jose.HeaderKey]interface{}{ "url": "https://example.com/acme/foo", }, } signer, err := jose.NewSigner(signerKey, opts) test.AssertNotError(rs.t, err, "Failed to make signer") jws, err := signer.Sign([]byte("")) test.AssertNotError(rs.t, err, "Failed to sign req") body := jws.FullSerialize() parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) test.AssertNotError(rs.t, err, "Failed to parse generated JWS") return parsedJWS } // shortNonce returns an otherwise well-signed request with a nonce shorter than // the prefix length. func (rs requestSigner) shortNonce() *jose.JSONWebSignature { privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) jwk := &jose.JSONWebKey{ Key: privateKey, Algorithm: keyAlgForKey(rs.t, privateKey), KeyID: "http://localhost/acme/acct/1", } signerKey := jose.SigningKey{ Key: jwk, Algorithm: jose.RS256, } opts := &jose.SignerOptions{ NonceSource: badNonceProvider{shortNonce: true}, ExtraHeaders: map[jose.HeaderKey]interface{}{ "url": "https://example.com/acme/foo", }, } signer, err := jose.NewSigner(signerKey, opts) test.AssertNotError(rs.t, err, "Failed to make signer") jws, err := signer.Sign([]byte("")) test.AssertNotError(rs.t, err, "Failed to sign req") body := jws.FullSerialize() parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) test.AssertNotError(rs.t, err, "Failed to parse generated JWS") return parsedJWS } func TestRejectsNone(t *testing.T) { noneJWSBody := ` { "header": { "alg": "none", "jwk": { "kty": "RSA", "n": "vrjT", "e": "AQAB" } }, "payload": "aGkK", "signature": "ghTIjrhiRl2pQ09vAkUUBbF5KziJdhzOTB-okM9SPRzU8Hyj0W1H5JA1Zoc-A-LuJGNAtYYHWqMw1SeZbT0l9FHcbMPeWDaJNkHS9jz5_g_Oyol8vcrWur2GDtB2Jgw6APtZKrbuGATbrF7g41Wijk6Kk9GXDoCnlfOQOhHhsrFFcWlCPLG-03TtKD6EBBoVBhmlp8DRLs7YguWRZ6jWNaEX-1WiRntBmhLqoqQFtvZxCBw_PRuaRw_RZBd1x2_BNYqEdOmVNC43UHMSJg3y_3yrPo905ur09aUTscf-C_m4Sa4M0FuDKn3bQ_pFrtz-aCCq6rcTIyxYpDqNvHMT2Q" } ` _, err := jose.ParseSigned(noneJWSBody, getSupportedAlgs()) test.AssertError(t, err, "Should not have been able to parse 'none' algorithm") } func TestRejectsHS256(t *testing.T) { hs256JWSBody := ` { "header": { "alg": "HS256", "jwk": { "kty": "RSA", "n": "vrjT", "e": "AQAB" } }, "payload": "aGkK", "signature": "ghTIjrhiRl2pQ09vAkUUBbF5KziJdhzOTB-okM9SPRzU8Hyj0W1H5JA1Zoc-A-LuJGNAtYYHWqMw1SeZbT0l9FHcbMPeWDaJNkHS9jz5_g_Oyol8vcrWur2GDtB2Jgw6APtZKrbuGATbrF7g41Wijk6Kk9GXDoCnlfOQOhHhsrFFcWlCPLG-03TtKD6EBBoVBhmlp8DRLs7YguWRZ6jWNaEX-1WiRntBmhLqoqQFtvZxCBw_PRuaRw_RZBd1x2_BNYqEdOmVNC43UHMSJg3y_3yrPo905ur09aUTscf-C_m4Sa4M0FuDKn3bQ_pFrtz-aCCq6rcTIyxYpDqNvHMT2Q" } ` _, err := jose.ParseSigned(hs256JWSBody, getSupportedAlgs()) fmt.Println(err) test.AssertError(t, err, "Parsed hs256JWSBody, but should not have") } func TestCheckAlgorithm(t *testing.T) { testCases := []struct { key jose.JSONWebKey jws jose.JSONWebSignature expectedErr string }{ { jose.JSONWebKey{}, jose.JSONWebSignature{ Signatures: []jose.Signature{ { Header: jose.Header{ Algorithm: "RS256", }, }, }, }, "JWK contains unsupported key type (expected RSA, or ECDSA P-256, P-384, or P-521)", }, { jose.JSONWebKey{ Algorithm: "HS256", Key: &rsa.PublicKey{}, }, jose.JSONWebSignature{ Signatures: []jose.Signature{ { Header: jose.Header{ Algorithm: "HS256", }, }, }, }, "JWS signature header contains unsupported algorithm \"HS256\", expected one of [RS256 ES256 ES384 ES512]", }, { jose.JSONWebKey{ Algorithm: "ES256", Key: &dsa.PublicKey{}, }, jose.JSONWebSignature{ Signatures: []jose.Signature{ { Header: jose.Header{ Algorithm: "ES512", }, }, }, }, "JWK contains unsupported key type (expected RSA, or ECDSA P-256, P-384, or P-521)", }, { jose.JSONWebKey{ Algorithm: "RS256", Key: &rsa.PublicKey{}, }, jose.JSONWebSignature{ Signatures: []jose.Signature{ { Header: jose.Header{ Algorithm: "ES512", }, }, }, }, "JWS signature header algorithm \"ES512\" does not match expected algorithm \"RS256\" for JWK", }, { jose.JSONWebKey{ Algorithm: "HS256", Key: &rsa.PublicKey{}, }, jose.JSONWebSignature{ Signatures: []jose.Signature{ { Header: jose.Header{ Algorithm: "RS256", }, }, }, }, "JWK key header algorithm \"HS256\" does not match expected algorithm \"RS256\" for JWK", }, } for i, tc := range testCases { err := checkAlgorithm(&tc.key, tc.jws.Signatures[0].Header) if tc.expectedErr != "" && err.Error() != tc.expectedErr { t.Errorf("TestCheckAlgorithm %d: Expected %q, got %q", i, tc.expectedErr, err) } } } func TestCheckAlgorithmSuccess(t *testing.T) { jwsRS256 := &jose.JSONWebSignature{ Signatures: []jose.Signature{ { Header: jose.Header{ Algorithm: "RS256", }, }, }, } goodJSONWebKeyRS256 := &jose.JSONWebKey{ Algorithm: "RS256", Key: &rsa.PublicKey{}, } err := checkAlgorithm(goodJSONWebKeyRS256, jwsRS256.Signatures[0].Header) test.AssertNotError(t, err, "RS256 key: Expected nil error") badJSONWebKeyRS256 := &jose.JSONWebKey{ Algorithm: "ObviouslyWrongButNotZeroValue", Key: &rsa.PublicKey{}, } err = checkAlgorithm(badJSONWebKeyRS256, jwsRS256.Signatures[0].Header) test.AssertError(t, err, "RS256 key: Expected nil error") test.AssertContains(t, err.Error(), "JWK key header algorithm \"ObviouslyWrongButNotZeroValue\" does not match expected algorithm \"RS256\" for JWK") jwsES256 := &jose.JSONWebSignature{ Signatures: []jose.Signature{ { Header: jose.Header{ Algorithm: "ES256", }, }, }, } goodJSONWebKeyES256 := &jose.JSONWebKey{ Algorithm: "ES256", Key: &ecdsa.PublicKey{ Curve: elliptic.P256(), }, } err = checkAlgorithm(goodJSONWebKeyES256, jwsES256.Signatures[0].Header) test.AssertNotError(t, err, "ES256 key: Expected nil error") badJSONWebKeyES256 := &jose.JSONWebKey{ Algorithm: "ObviouslyWrongButNotZeroValue", Key: &ecdsa.PublicKey{ Curve: elliptic.P256(), }, } err = checkAlgorithm(badJSONWebKeyES256, jwsES256.Signatures[0].Header) test.AssertError(t, err, "ES256 key: Expected nil error") test.AssertContains(t, err.Error(), "JWK key header algorithm \"ObviouslyWrongButNotZeroValue\" does not match expected algorithm \"ES256\" for JWK") } func TestValidPOSTRequest(t *testing.T) { wfe, _, _ := setupWFE(t) dummyContentLength := []string{"pretty long, idk, maybe a nibble or two?"} testCases := []struct { Name string Headers map[string][]string Body *string HTTPStatus int ErrorDetail string ErrorStatType string EnforceContentType bool }{ // POST requests without a Content-Length should produce a problem { Name: "POST without a Content-Length header", Headers: nil, HTTPStatus: http.StatusLengthRequired, ErrorDetail: "missing Content-Length header", ErrorStatType: "ContentLengthRequired", }, // POST requests with a Replay-Nonce header should produce a problem { Name: "POST with a Replay-Nonce HTTP header", Headers: map[string][]string{ "Content-Length": dummyContentLength, "Replay-Nonce": {"ima-misplaced-nonce"}, "Content-Type": {expectedJWSContentType}, }, HTTPStatus: http.StatusBadRequest, ErrorDetail: "HTTP requests should NOT contain Replay-Nonce header. Use JWS nonce field", ErrorStatType: "ReplayNonceOutsideJWS", }, // POST requests without a body should produce a problem { Name: "POST with an empty POST body", Headers: map[string][]string{ "Content-Length": dummyContentLength, "Content-Type": {expectedJWSContentType}, }, HTTPStatus: http.StatusBadRequest, ErrorDetail: "No body on POST", ErrorStatType: "NoPOSTBody", }, { Name: "POST without a Content-Type header", Headers: map[string][]string{ "Content-Length": dummyContentLength, }, HTTPStatus: http.StatusUnsupportedMediaType, ErrorDetail: fmt.Sprintf( "No Content-Type header on POST. Content-Type must be %q", expectedJWSContentType), ErrorStatType: "NoContentType", EnforceContentType: true, }, { Name: "POST with an invalid Content-Type header", Headers: map[string][]string{ "Content-Length": dummyContentLength, "Content-Type": {"fresh.and.rare"}, }, HTTPStatus: http.StatusUnsupportedMediaType, ErrorDetail: fmt.Sprintf( "Invalid Content-Type header on POST. Content-Type must be %q", expectedJWSContentType), ErrorStatType: "WrongContentType", EnforceContentType: true, }, } for _, tc := range testCases { input := &http.Request{ Method: "POST", URL: mustParseURL("/"), Header: tc.Headers, } t.Run(tc.Name, func(t *testing.T) { err := wfe.validPOSTRequest(input) test.AssertError(t, err, "No error returned for invalid POST") test.AssertErrorIs(t, err, berrors.Malformed) test.AssertContains(t, err.Error(), tc.ErrorDetail) test.AssertMetricWithLabelsEquals( t, wfe.stats.httpErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) }) } } func TestEnforceJWSAuthType(t *testing.T) { wfe, _, signer := setupWFE(t) testKeyIDJWS, _, _ := signer.byKeyID(1, nil, "", "") testEmbeddedJWS, _, _ := signer.embeddedJWK(nil, "", "") // A hand crafted JWS that has both a Key ID and an embedded JWK conflictJWSBody := ` { "header": { "alg": "RS256", "jwk": { "e": "AQAB", "kty": "RSA", "n": "ppbqGaMFnnq9TeMUryR6WW4Lr5WMgp46KlBXZkNaGDNQoifWt6LheeR5j9MgYkIFU7Z8Jw5-bpJzuBeEVwb-yHGh4Umwo_qKtvAJd44iLjBmhBSxq-OSe6P5hX1LGCByEZlYCyoy98zOtio8VK_XyS5VoOXqchCzBXYf32ksVUTrtH1jSlamKHGz0Q0pRKIsA2fLqkE_MD3jP6wUDD6ExMw_tKYLx21lGcK41WSrRpDH-kcZo1QdgCy2ceNzaliBX1eHmKG0-H8tY4tPQudk-oHQmWTdvUIiHO6gSKMGDZNWv6bq74VTCsRfUEAkuWhqUhgRSGzlvlZ24wjHv5Qdlw" } }, "protected": "eyJub25jZSI6ICJibTl1WTJVIiwgInVybCI6ICJodHRwOi8vbG9jYWxob3N0L3Rlc3QiLCAia2lkIjogInRlc3RrZXkifQ", "payload": "Zm9v", "signature": "ghTIjrhiRl2pQ09vAkUUBbF5KziJdhzOTB-okM9SPRzU8Hyj0W1H5JA1Zoc-A-LuJGNAtYYHWqMw1SeZbT0l9FHcbMPeWDaJNkHS9jz5_g_Oyol8vcrWur2GDtB2Jgw6APtZKrbuGATbrF7g41Wijk6Kk9GXDoCnlfOQOhHhsrFFcWlCPLG-03TtKD6EBBoVBhmlp8DRLs7YguWRZ6jWNaEX-1WiRntBmhLqoqQFtvZxCBw_PRuaRw_RZBd1x2_BNYqEdOmVNC43UHMSJg3y_3yrPo905ur09aUTscf-C_m4Sa4M0FuDKn3bQ_pFrtz-aCCq6rcTIyxYpDqNvHMT2Q" } ` conflictJWS, err := jose.ParseSigned(conflictJWSBody, getSupportedAlgs()) if err != nil { t.Fatal("Unable to parse conflict JWS") } testCases := []struct { Name string JWS *jose.JSONWebSignature AuthType jwsAuthType WantErrType berrors.ErrorType WantErrDetail string WantStatType string }{ { Name: "Key ID and embedded JWS", JWS: conflictJWS, AuthType: invalidAuthType, WantErrType: berrors.Malformed, WantErrDetail: "jwk and kid header fields are mutually exclusive", WantStatType: "JWSAuthTypeInvalid", }, { Name: "Key ID when expected is embedded JWK", JWS: testKeyIDJWS, AuthType: embeddedJWK, WantErrType: berrors.Malformed, WantErrDetail: "No embedded JWK in JWS header", WantStatType: "JWSAuthTypeWrong", }, { Name: "Embedded JWK when expected is Key ID", JWS: testEmbeddedJWS, AuthType: embeddedKeyID, WantErrType: berrors.Malformed, WantErrDetail: "No Key ID in JWS header", WantStatType: "JWSAuthTypeWrong", }, { Name: "Key ID when expected is KeyID", JWS: testKeyIDJWS, AuthType: embeddedKeyID, }, { Name: "Embedded JWK when expected is embedded JWK", JWS: testEmbeddedJWS, AuthType: embeddedJWK, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { wfe.stats.joseErrorCount.Reset() in := tc.JWS.Signatures[0].Header gotErr := wfe.enforceJWSAuthType(in, tc.AuthType) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("enforceJWSAuthType(%#v, %#v) = %#v, want nil", in, tc.AuthType, gotErr) } } else { berr, ok := gotErr.(*berrors.BoulderError) if !ok { t.Fatalf("enforceJWSAuthType(%#v, %#v) returned %T, want BoulderError", in, tc.AuthType, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("enforceJWSAuthType(%#v, %#v) = %#v, want %#v", in, tc.AuthType, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("enforceJWSAuthType(%#v, %#v) = %q, want %q", in, tc.AuthType, berr.Detail, tc.WantErrDetail) } test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.WantStatType}, 1) } }) } } type badNonceProvider struct { malformed bool shortNonce bool } func (b badNonceProvider) Nonce() (string, error) { if b.malformed { return "im-a-nonce", nil } if b.shortNonce { // A nonce length of 4 is considered "short" because there is no nonce // material to be redeemed after the prefix. Derived prefixes are 8 // characters and static prefixes are 4 characters. return "woww", nil } return "mlolmlol3ov77I5Ui-cdaY_k8IcjK58FvbG0y_BCRrx5rGQ8rjA", nil } func TestValidNonce(t *testing.T) { wfe, _, signer := setupWFE(t) goodJWS, _, _ := signer.embeddedJWK(nil, "", "") testCases := []struct { Name string JWS *jose.JSONWebSignature WantErrType berrors.ErrorType WantErrDetail string WantStatType string }{ { Name: "No nonce in JWS", JWS: signer.missingNonce(), WantErrType: berrors.BadNonce, WantErrDetail: "JWS has no anti-replay nonce", WantStatType: "JWSMissingNonce", }, { Name: "Malformed nonce in JWS", JWS: signer.malformedNonce(), WantErrType: berrors.BadNonce, WantErrDetail: "JWS has an invalid anti-replay nonce: \"im-a-nonce\"", WantStatType: "JWSMalformedNonce", }, { Name: "Canned nonce shorter than prefixLength in JWS", JWS: signer.shortNonce(), WantErrType: berrors.BadNonce, WantErrDetail: "JWS has an invalid anti-replay nonce: \"woww\"", WantStatType: "JWSMalformedNonce", }, { Name: "Invalid nonce in JWS (test/config-next)", JWS: signer.invalidNonce(), WantErrType: berrors.BadNonce, WantErrDetail: "JWS has an invalid anti-replay nonce: \"mlolmlol3ov77I5Ui-cdaY_k8IcjK58FvbG0y_BCRrx5rGQ8rjA\"", WantStatType: "JWSInvalidNonce", }, { Name: "Valid nonce in JWS", JWS: goodJWS, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { in := tc.JWS.Signatures[0].Header wfe.stats.joseErrorCount.Reset() gotErr := wfe.validNonce(context.Background(), in) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("validNonce(%#v) = %#v, want nil", in, gotErr) } } else { berr, ok := gotErr.(*berrors.BoulderError) if !ok { t.Fatalf("validNonce(%#v) returned %T, want BoulderError", in, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("validNonce(%#v) = %#v, want %#v", in, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("validNonce(%#v) = %q, want %q", in, berr.Detail, tc.WantErrDetail) } test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.WantStatType}, 1) } }) } } // noBackendsNonceRedeemer is a nonce redeemer that always returns an error // indicating that the prefix matches no known nonce provider. type noBackendsNonceRedeemer struct{} func (n noBackendsNonceRedeemer) Redeem(ctx context.Context, _ *noncepb.NonceMessage, opts ...grpc.CallOption) (*noncepb.ValidMessage, error) { return nil, noncebalancer.ErrNoBackendsMatchPrefix.Err() } func TestValidNonce_NoMatchingBackendFound(t *testing.T) { wfe, _, signer := setupWFE(t) goodJWS, _, _ := signer.embeddedJWK(nil, "", "") wfe.rnc = noBackendsNonceRedeemer{} // A valid JWS with a nonce whose prefix matches no known nonce provider should // result in a BadNonceProblem. err := wfe.validNonce(context.Background(), goodJWS.Signatures[0].Header) test.AssertError(t, err, "Expected error for valid nonce with no backend") test.AssertErrorIs(t, err, berrors.BadNonce) test.AssertContains(t, err.Error(), "JWS has an invalid anti-replay nonce") test.AssertMetricWithLabelsEquals(t, wfe.stats.nonceNoMatchingBackendCount, prometheus.Labels{}, 1) } func (rs requestSigner) signExtraHeaders( headers map[jose.HeaderKey]interface{}) (*jose.JSONWebSignature, string) { privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) signerKey := jose.SigningKey{ Key: privateKey, Algorithm: sigAlgForKey(rs.t, privateKey.Public()), } opts := &jose.SignerOptions{ NonceSource: rs.nonceService, EmbedJWK: true, ExtraHeaders: headers, } signer, err := jose.NewSigner(signerKey, opts) test.AssertNotError(rs.t, err, "Failed to make signer") jws, err := signer.Sign([]byte("")) test.AssertNotError(rs.t, err, "Failed to sign req") body := jws.FullSerialize() parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) test.AssertNotError(rs.t, err, "Failed to parse generated JWS") return parsedJWS, body } func TestValidPOSTURL(t *testing.T) { wfe, _, signer := setupWFE(t) // A JWS and HTTP request with no extra headers noHeadersJWS, noHeadersJWSBody := signer.signExtraHeaders(nil) noHeadersRequest := makePostRequestWithPath("test-path", noHeadersJWSBody) // A JWS and HTTP request with extra headers, but no "url" extra header noURLHeaders := map[jose.HeaderKey]interface{}{ "nifty": "swell", } noURLHeaderJWS, noURLHeaderJWSBody := signer.signExtraHeaders(noURLHeaders) noURLHeaderRequest := makePostRequestWithPath("test-path", noURLHeaderJWSBody) // A JWS and HTTP request with a mismatched HTTP URL to JWS "url" header wrongURLHeaders := map[jose.HeaderKey]interface{}{ "url": "foobar", } wrongURLHeaderJWS, wrongURLHeaderJWSBody := signer.signExtraHeaders(wrongURLHeaders) wrongURLHeaderRequest := makePostRequestWithPath("test-path", wrongURLHeaderJWSBody) correctURLHeaderJWS, _, correctURLHeaderJWSBody := signer.embeddedJWK(nil, "http://localhost/test-path", "") correctURLHeaderRequest := makePostRequestWithPath("test-path", correctURLHeaderJWSBody) testCases := []struct { Name string JWS *jose.JSONWebSignature Request *http.Request WantErrType berrors.ErrorType WantErrDetail string WantStatType string }{ { Name: "No extra headers in JWS", JWS: noHeadersJWS, Request: noHeadersRequest, WantErrType: berrors.Malformed, WantErrDetail: "JWS header parameter 'url' required", WantStatType: "JWSNoExtraHeaders", }, { Name: "No URL header in JWS", JWS: noURLHeaderJWS, Request: noURLHeaderRequest, WantErrType: berrors.Malformed, WantErrDetail: "JWS header parameter 'url' required", WantStatType: "JWSMissingURL", }, { Name: "Wrong URL header in JWS", JWS: wrongURLHeaderJWS, Request: wrongURLHeaderRequest, WantErrType: berrors.Malformed, WantErrDetail: "JWS header parameter 'url' incorrect. Expected \"http://localhost/test-path\" got \"foobar\"", WantStatType: "JWSMismatchedURL", }, { Name: "Correct URL header in JWS", JWS: correctURLHeaderJWS, Request: correctURLHeaderRequest, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { in := tc.JWS.Signatures[0].Header tc.Request.Header.Add("Content-Type", expectedJWSContentType) wfe.stats.joseErrorCount.Reset() got := wfe.validPOSTURL(tc.Request, in) if tc.WantErrDetail == "" { if got != nil { t.Fatalf("validPOSTURL(%#v) = %#v, want nil", in, got) } } else { berr, ok := got.(*berrors.BoulderError) if !ok { t.Fatalf("validPOSTURL(%#v) returned %T, want BoulderError", in, got) } if berr.Type != tc.WantErrType { t.Errorf("validPOSTURL(%#v) = %#v, want %#v", in, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("validPOSTURL(%#v) = %q, want %q", in, berr.Detail, tc.WantErrDetail) } test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.WantStatType}, 1) } }) } } func (rs requestSigner) multiSigJWS() (*jose.JSONWebSignature, string) { privateKeyA := loadKey(rs.t, []byte(test1KeyPrivatePEM)) privateKeyB := loadKey(rs.t, []byte(test2KeyPrivatePEM)) signerKeyA := jose.SigningKey{ Key: privateKeyA, Algorithm: sigAlgForKey(rs.t, privateKeyA.Public()), } signerKeyB := jose.SigningKey{ Key: privateKeyB, Algorithm: sigAlgForKey(rs.t, privateKeyB.Public()), } opts := &jose.SignerOptions{ NonceSource: rs.nonceService, EmbedJWK: true, } signer, err := jose.NewMultiSigner([]jose.SigningKey{signerKeyA, signerKeyB}, opts) test.AssertNotError(rs.t, err, "Failed to make multi signer") jws, err := signer.Sign([]byte("")) test.AssertNotError(rs.t, err, "Failed to sign req") body := jws.FullSerialize() parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) test.AssertNotError(rs.t, err, "Failed to parse generated JWS") return parsedJWS, body } func TestParseJWSRequest(t *testing.T) { wfe, _, signer := setupWFE(t) _, tooManySigsJWSBody := signer.multiSigJWS() _, _, validJWSBody := signer.embeddedJWK(nil, "http://localhost/test-path", "") validJWSRequest := makePostRequestWithPath("test-path", validJWSBody) missingSigsJWSBody := `{"payload":"Zm9x","protected":"eyJhbGciOiJSUzI1NiIsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoicW5BUkxyVDdYejRnUmNLeUxkeWRtQ3ItZXk5T3VQSW1YNFg0MHRoazNvbjI2RmtNem5SM2ZSanM2NmVMSzdtbVBjQlo2dU9Kc2VVUlU2d0FhWk5tZW1vWXgxZE12cXZXV0l5aVFsZUhTRDdROHZCcmhSNnVJb080akF6SlpSLUNoelp1U0R0N2lITi0zeFVWc3B1NVhHd1hVX01WSlpzaFR3cDRUYUZ4NWVsSElUX09iblR2VE9VM1hoaXNoMDdBYmdaS21Xc1ZiWGg1cy1DcklpY1U0T2V4SlBndW5XWl9ZSkp1ZU9LbVR2bkxsVFY0TXpLUjJvWmxCS1oyN1MwLVNmZFZfUUR4X3lkbGU1b01BeUtWdGxBVjM1Y3lQTUlzWU53Z1VHQkNkWV8yVXppNWVYMGxUYzdNUFJ3ejZxUjFraXAtaTU5VmNHY1VRZ3FIVjZGeXF3IiwiZSI6IkFRQUIifSwia2lkIjoiIiwibm9uY2UiOiJyNHpuenZQQUVwMDlDN1JwZUtYVHhvNkx3SGwxZVBVdmpGeXhOSE1hQnVvIiwidXJsIjoiaHR0cDovL2xvY2FsaG9zdC9hY21lL25ldy1yZWcifQ"}` missingSigsJWSRequest := makePostRequestWithPath("test-path", missingSigsJWSBody) unprotectedHeadersJWSBody := ` { "header": { "alg": "RS256", "kid": "unprotected key id" }, "protected": "eyJub25jZSI6ICJibTl1WTJVIiwgInVybCI6ICJodHRwOi8vbG9jYWxob3N0L3Rlc3QiLCAia2lkIjogInRlc3RrZXkifQ", "payload": "Zm9v", "signature": "PKWWclRsiHF4bm-nmpxDez6Y_3Mdtu263YeYklbGYt1EiMOLiKY_dr_EqhUUKAKEWysFLO-hQLXVU7kVkHeYWQFFOA18oFgcZgkSF2Pr3DNZrVj9e2gl0eZ2i2jk6X5GYPt1lIfok_DrL92wrxEKGcrmxqXXGm0JgP6Al2VGapKZK2HaYbCHoGvtzNmzUX9rC21sKewq5CquJRvTmvQp5bmU7Q9KeafGibFr0jl6IA3W5LBGgf6xftuUtEVEbKmKaKtaG7tXsQH1mIVOPUZZoLWz9sWJSFLmV0QSXm3ZHV0DrOhLfcADbOCoQBMeGdseBQZuUO541A3BEKGv2Aikjw" } ` wrongSignaturesFieldJWSBody := ` { "protected": "eyJub25jZSI6ICJibTl1WTJVIiwgInVybCI6ICJodHRwOi8vbG9jYWxob3N0L3Rlc3QiLCAia2lkIjogInRlc3RrZXkifQ", "payload": "Zm9v", "signatures": ["PKWWclRsiHF4bm-nmpxDez6Y_3Mdtu263YeYklbGYt1EiMOLiKY_dr_EqhUUKAKEWysFLO-hQLXVU7kVkHeYWQFFOA18oFgcZgkSF2Pr3DNZrVj9e2gl0eZ2i2jk6X5GYPt1lIfok_DrL92wrxEKGcrmxqXXGm0JgP6Al2VGapKZK2HaYbCHoGvtzNmzUX9rC21sKewq5CquJRvTmvQp5bmU7Q9KeafGibFr0jl6IA3W5LBGgf6xftuUtEVEbKmKaKtaG7tXsQH1mIVOPUZZoLWz9sWJSFLmV0QSXm3ZHV0DrOhLfcADbOCoQBMeGdseBQZuUO541A3BEKGv2Aikjw"] } ` wrongSignatureTypeJWSBody := ` { "protected": "eyJhbGciOiJIUzI1NiJ9", "payload" : "IiI", "signature" : "5WiUupHzCWfpJza6EMteSxMDY8_6xIV7HnKaUqmykIQ" } ` testCases := []struct { Name string Request *http.Request WantErrType berrors.ErrorType WantErrDetail string WantStatType string }{ { Name: "Invalid POST request", // No Content-Length, something that validPOSTRequest should be flagging Request: &http.Request{ Method: "POST", URL: mustParseURL("/"), }, WantErrType: berrors.Malformed, WantErrDetail: "missing Content-Length header", }, { Name: "Invalid JWS in POST body", Request: makePostRequestWithPath("test-path", `{`), WantErrType: berrors.Malformed, WantErrDetail: "Parse error reading JWS", WantStatType: "JWSUnmarshalFailed", }, { Name: "Too few signatures in JWS", Request: missingSigsJWSRequest, WantErrType: berrors.Malformed, WantErrDetail: "POST JWS not signed", WantStatType: "JWSEmptySignature", }, { Name: "Too many signatures in JWS", Request: makePostRequestWithPath("test-path", tooManySigsJWSBody), WantErrType: berrors.Malformed, WantErrDetail: "JWS \"signatures\" field not allowed. Only the \"signature\" field should contain a signature", WantStatType: "JWSMultiSig", }, { Name: "Unprotected JWS headers", Request: makePostRequestWithPath("test-path", unprotectedHeadersJWSBody), WantErrType: berrors.Malformed, WantErrDetail: "JWS \"header\" field not allowed. All headers must be in \"protected\" field", WantStatType: "JWSUnprotectedHeaders", }, { Name: "Unsupported signatures field in JWS", Request: makePostRequestWithPath("test-path", wrongSignaturesFieldJWSBody), WantErrType: berrors.Malformed, WantErrDetail: "JWS \"signatures\" field not allowed. Only the \"signature\" field should contain a signature", WantStatType: "JWSMultiSig", }, { Name: "JWS with an invalid algorithm", Request: makePostRequestWithPath("test-path", wrongSignatureTypeJWSBody), WantErrType: berrors.BadSignatureAlgorithm, WantErrDetail: "JWS signature header contains unsupported algorithm \"HS256\", expected one of [RS256 ES256 ES384 ES512]", WantStatType: "JWSAlgorithmCheckFailed", }, { Name: "Valid JWS in POST request", Request: validJWSRequest, }, { Name: "POST body too large", Request: makePostRequestWithPath("test-path", fmt.Sprintf(`{"a":"%s"}`, strings.Repeat("a", 50000))), WantErrType: berrors.Unauthorized, WantErrDetail: "request body too large", }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { wfe.stats.joseErrorCount.Reset() _, gotErr := wfe.parseJWSRequest(tc.Request) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("parseJWSRequest(%#v) = %#v, want nil", tc.Request, gotErr) } } else { berr, ok := gotErr.(*berrors.BoulderError) if !ok { t.Fatalf("parseJWSRequest(%#v) returned %T, want BoulderError", tc.Request, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("parseJWSRequest(%#v) = %#v, want %#v", tc.Request, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("parseJWSRequest(%#v) = %q, want %q", tc.Request, berr.Detail, tc.WantErrDetail) } if tc.WantStatType != "" { test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.WantStatType}, 1) } } }) } } func TestExtractJWK(t *testing.T) { wfe, _, signer := setupWFE(t) keyIDJWS, _, _ := signer.byKeyID(1, nil, "", "") goodJWS, goodJWK, _ := signer.embeddedJWK(nil, "", "") testCases := []struct { Name string JWS *jose.JSONWebSignature WantKey *jose.JSONWebKey WantErrType berrors.ErrorType WantErrDetail string }{ { Name: "JWS with wrong auth type (Key ID vs embedded JWK)", JWS: keyIDJWS, WantErrType: berrors.Malformed, WantErrDetail: "No embedded JWK in JWS header", }, { Name: "Valid JWS with embedded JWK", JWS: goodJWS, WantKey: goodJWK, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { in := tc.JWS.Signatures[0].Header gotKey, gotErr := wfe.extractJWK(in) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("extractJWK(%#v) = %#v, want nil", in, gotKey) } test.AssertMarshaledEquals(t, gotKey, tc.WantKey) } else { berr, ok := gotErr.(*berrors.BoulderError) if !ok { t.Fatalf("extractJWK(%#v) returned %T, want BoulderError", in, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("extractJWK(%#v) = %#v, want %#v", in, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("extractJWK(%#v) = %q, want %q", in, berr.Detail, tc.WantErrDetail) } } }) } } func (rs requestSigner) specifyKeyID(keyID string) (*jose.JSONWebSignature, string) { privateKey := loadKey(rs.t, []byte(test1KeyPrivatePEM)) if keyID == "" { keyID = "this is an invalid non-numeric key ID" } jwk := &jose.JSONWebKey{ Key: privateKey, Algorithm: "RSA", KeyID: keyID, } signerKey := jose.SigningKey{ Key: jwk, Algorithm: jose.RS256, } opts := &jose.SignerOptions{ NonceSource: rs.nonceService, ExtraHeaders: map[jose.HeaderKey]interface{}{ "url": "http://localhost", }, } signer, err := jose.NewSigner(signerKey, opts) test.AssertNotError(rs.t, err, "Failed to make signer") jws, err := signer.Sign([]byte("")) test.AssertNotError(rs.t, err, "Failed to sign req") body := jws.FullSerialize() parsedJWS, err := jose.ParseSigned(body, getSupportedAlgs()) test.AssertNotError(rs.t, err, "Failed to parse generated JWS") return parsedJWS, body } func TestLookupJWK(t *testing.T) { wfe, _, signer := setupWFE(t) embeddedJWS, _, embeddedJWSBody := signer.embeddedJWK(nil, "", "") invalidKeyIDJWS, invalidKeyIDJWSBody := signer.specifyKeyID("https://acme-99.lettuceencrypt.org/acme/reg/1") // ID 100 is mocked to return a non-missing error from sa.GetRegistration errorIDJWS, _, errorIDJWSBody := signer.byKeyID(100, nil, "", "") // ID 102 is mocked to return an account does not exist error from sa.GetRegistration missingIDJWS, _, missingIDJWSBody := signer.byKeyID(102, nil, "", "") // ID 3 is mocked to return a deactivated account from sa.GetRegistration deactivatedIDJWS, _, deactivatedIDJWSBody := signer.byKeyID(3, nil, "", "") wfe.LegacyKeyIDPrefix = "https://acme-v00.lettuceencrypt.org/acme/reg/" legacyKeyIDJWS, legacyKeyIDJWSBody := signer.specifyKeyID(wfe.LegacyKeyIDPrefix + "1") nonNumericKeyIDJWS, nonNumericKeyIDJWSBody := signer.specifyKeyID(wfe.LegacyKeyIDPrefix + "abcd") validJWS, validKey, validJWSBody := signer.byKeyID(1, nil, "", "") validAccountPB, _ := wfe.sa.GetRegistration(context.Background(), &sapb.RegistrationID{Id: 1}) validAccount, _ := bgrpc.PbToRegistration(validAccountPB) // good key, log event requester is set testCases := []struct { Name string JWS *jose.JSONWebSignature Request *http.Request WantJWK *jose.JSONWebKey WantAccount *core.Registration WantErrType berrors.ErrorType WantErrDetail string WantStatType string }{ { Name: "JWS with wrong auth type (embedded JWK vs Key ID)", JWS: embeddedJWS, Request: makePostRequestWithPath("test-path", embeddedJWSBody), WantErrType: berrors.Malformed, WantErrDetail: "No Key ID in JWS header", WantStatType: "JWSAuthTypeWrong", }, { Name: "JWS with invalid key ID URL", JWS: invalidKeyIDJWS, Request: makePostRequestWithPath("test-path", invalidKeyIDJWSBody), WantErrType: berrors.Malformed, WantErrDetail: "KeyID header contained an invalid account URL: \"https://acme-99.lettuceencrypt.org/acme/reg/1\"", WantStatType: "JWSInvalidKeyID", }, { Name: "JWS with non-numeric account ID in key ID URL", JWS: nonNumericKeyIDJWS, Request: makePostRequestWithPath("test-path", nonNumericKeyIDJWSBody), WantErrType: berrors.Malformed, WantErrDetail: "Malformed account ID in KeyID header URL: \"https://acme-v00.lettuceencrypt.org/acme/reg/abcd\"", WantStatType: "JWSInvalidKeyID", }, { Name: "JWS with account ID that causes GetRegistration error", JWS: errorIDJWS, Request: makePostRequestWithPath("test-path", errorIDJWSBody), WantErrType: berrors.InternalServer, WantErrDetail: "Error retrieving account \"http://localhost/acme/acct/100\"", WantStatType: "JWSKeyIDLookupFailed", }, { Name: "JWS with account ID that doesn't exist", JWS: missingIDJWS, Request: makePostRequestWithPath("test-path", missingIDJWSBody), WantErrType: berrors.AccountDoesNotExist, WantErrDetail: "Account \"http://localhost/acme/acct/102\" not found", WantStatType: "JWSKeyIDNotFound", }, { Name: "JWS with account ID that is deactivated", JWS: deactivatedIDJWS, Request: makePostRequestWithPath("test-path", deactivatedIDJWSBody), WantErrType: berrors.Unauthorized, WantErrDetail: "Account is not valid, has status \"deactivated\"", WantStatType: "JWSKeyIDAccountInvalid", }, { Name: "Valid JWS with legacy account ID", JWS: legacyKeyIDJWS, Request: makePostRequestWithPath("test-path", legacyKeyIDJWSBody), WantJWK: validKey, WantAccount: &validAccount, }, { Name: "Valid JWS with valid account ID", JWS: validJWS, Request: makePostRequestWithPath("test-path", validJWSBody), WantJWK: validKey, WantAccount: &validAccount, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { wfe.stats.joseErrorCount.Reset() in := tc.JWS.Signatures[0].Header inputLogEvent := newRequestEvent() gotJWK, gotAcct, gotErr := wfe.lookupJWK(in, context.Background(), tc.Request, inputLogEvent) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("lookupJWK(%#v) = %#v, want nil", in, gotErr) } gotThumb, _ := gotJWK.Thumbprint(crypto.SHA256) wantThumb, _ := tc.WantJWK.Thumbprint(crypto.SHA256) if !slices.Equal(gotThumb, wantThumb) { t.Fatalf("lookupJWK(%#v) = %#v, want %#v", tc.Request, gotThumb, wantThumb) } test.AssertMarshaledEquals(t, gotAcct, tc.WantAccount) test.AssertEquals(t, inputLogEvent.Requester, gotAcct.ID) } else { var berr *berrors.BoulderError ok := errors.As(gotErr, &berr) if !ok { t.Fatalf("lookupJWK(%#v) returned %T, want BoulderError", in, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("lookupJWK(%#v) = %#v, want %#v", in, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("lookupJWK(%#v) = %q, want %q", in, berr.Detail, tc.WantErrDetail) } test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.WantStatType}, 1) } }) } } func TestValidJWSForKey(t *testing.T) { wfe, _, signer := setupWFE(t) payload := `{ "test": "payload" }` testURL := "http://localhost/test" goodJWS, goodJWK, _ := signer.embeddedJWK(nil, testURL, payload) // badSigJWSBody is a JWS that has had the payload changed by 1 byte to break the signature badSigJWSBody := `{"payload":"Zm9x","protected":"eyJhbGciOiJSUzI1NiIsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoicW5BUkxyVDdYejRnUmNLeUxkeWRtQ3ItZXk5T3VQSW1YNFg0MHRoazNvbjI2RmtNem5SM2ZSanM2NmVMSzdtbVBjQlo2dU9Kc2VVUlU2d0FhWk5tZW1vWXgxZE12cXZXV0l5aVFsZUhTRDdROHZCcmhSNnVJb080akF6SlpSLUNoelp1U0R0N2lITi0zeFVWc3B1NVhHd1hVX01WSlpzaFR3cDRUYUZ4NWVsSElUX09iblR2VE9VM1hoaXNoMDdBYmdaS21Xc1ZiWGg1cy1DcklpY1U0T2V4SlBndW5XWl9ZSkp1ZU9LbVR2bkxsVFY0TXpLUjJvWmxCS1oyN1MwLVNmZFZfUUR4X3lkbGU1b01BeUtWdGxBVjM1Y3lQTUlzWU53Z1VHQkNkWV8yVXppNWVYMGxUYzdNUFJ3ejZxUjFraXAtaTU5VmNHY1VRZ3FIVjZGeXF3IiwiZSI6IkFRQUIifSwia2lkIjoiIiwibm9uY2UiOiJyNHpuenZQQUVwMDlDN1JwZUtYVHhvNkx3SGwxZVBVdmpGeXhOSE1hQnVvIiwidXJsIjoiaHR0cDovL2xvY2FsaG9zdC9hY21lL25ldy1yZWcifQ","signature":"jcTdxSygm_cvD7KbXqsxgnoPApCTSkV4jolToSOd2ciRkg5W7Yl0ZKEEKwOc-dYIbQiwGiDzisyPCicwWsOUA1WSqHylKvZ3nxSMc6KtwJCW2DaOqcf0EEjy5VjiZJUrOt2c-r6b07tbn8sfOJKwlF2lsOeGi4s-rtvvkeQpAU-AWauzl9G4bv2nDUeCviAZjHx_PoUC-f9GmZhYrbDzAvXZ859ktM6RmMeD0OqPN7bhAeju2j9Gl0lnryZMtq2m0J2m1ucenQBL1g4ZkP1JiJvzd2cAz5G7Ftl2YeJJyWhqNd3qq0GVOt1P11s8PTGNaSoM0iR9QfUxT9A6jxARtg"}` badJWS, err := jose.ParseSigned(badSigJWSBody, getSupportedAlgs()) test.AssertNotError(t, err, "error loading badSigJWS body") // wrongAlgJWS is a JWS that has an invalid "HS256" algorithm in its header wrongAlgJWS := &jose.JSONWebSignature{ Signatures: []jose.Signature{ { Header: jose.Header{ Algorithm: "HS256", }, }, }, } // A JWS and HTTP request with a mismatched HTTP URL to JWS "url" header wrongURLHeaders := map[jose.HeaderKey]interface{}{ "url": "foobar", } wrongURLHeaderJWS, _ := signer.signExtraHeaders(wrongURLHeaders) // badJSONJWS has a valid signature over a body that is not valid JSON badJSONJWS, _, _ := signer.embeddedJWK(nil, testURL, `{`) testCases := []struct { Name string JWS bJSONWebSignature JWK *jose.JSONWebKey Body string WantErrType berrors.ErrorType WantErrDetail string WantStatType string }{ { Name: "JWS with an invalid algorithm", JWS: bJSONWebSignature{wrongAlgJWS}, JWK: goodJWK, WantErrType: berrors.BadSignatureAlgorithm, WantErrDetail: "JWS signature header contains unsupported algorithm \"HS256\", expected one of [RS256 ES256 ES384 ES512]", WantStatType: "JWSAlgorithmCheckFailed", }, { Name: "JWS with an invalid nonce (test/config-next)", JWS: bJSONWebSignature{signer.invalidNonce()}, JWK: goodJWK, WantErrType: berrors.BadNonce, WantErrDetail: "JWS has an invalid anti-replay nonce: \"mlolmlol3ov77I5Ui-cdaY_k8IcjK58FvbG0y_BCRrx5rGQ8rjA\"", WantStatType: "JWSInvalidNonce", }, { Name: "JWS with broken signature", JWS: bJSONWebSignature{badJWS}, JWK: badJWS.Signatures[0].Header.JSONWebKey, WantErrType: berrors.Malformed, WantErrDetail: "JWS verification error", WantStatType: "JWSVerifyFailed", }, { Name: "JWS with incorrect URL", JWS: bJSONWebSignature{wrongURLHeaderJWS}, JWK: wrongURLHeaderJWS.Signatures[0].Header.JSONWebKey, WantErrType: berrors.Malformed, WantErrDetail: "JWS header parameter 'url' incorrect. Expected \"http://localhost/test\" got \"foobar\"", WantStatType: "JWSMismatchedURL", }, { Name: "Valid JWS with invalid JSON in the protected body", JWS: bJSONWebSignature{badJSONJWS}, JWK: goodJWK, WantErrType: berrors.Malformed, WantErrDetail: "Request payload did not parse as JSON", WantStatType: "JWSBodyUnmarshalFailed", }, { Name: "Good JWS and JWK", JWS: bJSONWebSignature{goodJWS}, JWK: goodJWK, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { wfe.stats.joseErrorCount.Reset() request := makePostRequestWithPath("test", tc.Body) gotPayload, gotErr := wfe.validJWSForKey(context.Background(), &tc.JWS, tc.JWK, request) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("validJWSForKey(%#v, %#v, %#v) = %#v, want nil", tc.JWS, tc.JWK, request, gotErr) } if string(gotPayload) != payload { t.Fatalf("validJWSForKey(%#v, %#v, %#v) = %q, want %q", tc.JWS, tc.JWK, request, string(gotPayload), payload) } } else { berr, ok := gotErr.(*berrors.BoulderError) if !ok { t.Fatalf("validJWSForKey(%#v, %#v, %#v) returned %T, want BoulderError", tc.JWS, tc.JWK, request, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("validJWSForKey(%#v, %#v, %#v) = %#v, want %#v", tc.JWS, tc.JWK, request, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("validJWSForKey(%#v, %#v, %#v) = %q, want %q", tc.JWS, tc.JWK, request, berr.Detail, tc.WantErrDetail) } test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.WantStatType}, 1) } }) } } func TestValidPOSTForAccount(t *testing.T) { wfe, _, signer := setupWFE(t) validJWS, _, validJWSBody := signer.byKeyID(1, nil, "http://localhost/test", `{"test":"passed"}`) validAccountPB, _ := wfe.sa.GetRegistration(context.Background(), &sapb.RegistrationID{Id: 1}) validAccount, _ := bgrpc.PbToRegistration(validAccountPB) // ID 102 is mocked to return missing _, _, missingJWSBody := signer.byKeyID(102, nil, "http://localhost/test", "{}") // ID 3 is mocked to return deactivated key3 := loadKey(t, []byte(test3KeyPrivatePEM)) _, _, deactivatedJWSBody := signer.byKeyID(3, key3, "http://localhost/test", "{}") _, _, embeddedJWSBody := signer.embeddedJWK(nil, "http://localhost/test", `{"test":"passed"}`) testCases := []struct { Name string Request *http.Request WantPayload string WantAcct *core.Registration WantJWS *jose.JSONWebSignature WantErrType berrors.ErrorType WantErrDetail string WantStatType string }{ { Name: "Invalid JWS", Request: makePostRequestWithPath("test", "foo"), WantErrType: berrors.Malformed, WantErrDetail: "Parse error reading JWS", WantStatType: "JWSUnmarshalFailed", }, { Name: "Embedded Key JWS", Request: makePostRequestWithPath("test", embeddedJWSBody), WantErrType: berrors.Malformed, WantErrDetail: "No Key ID in JWS header", WantStatType: "JWSAuthTypeWrong", }, { Name: "JWS signed by account that doesn't exist", Request: makePostRequestWithPath("test", missingJWSBody), WantErrType: berrors.AccountDoesNotExist, WantErrDetail: "Account \"http://localhost/acme/acct/102\" not found", WantStatType: "JWSKeyIDNotFound", }, { Name: "JWS signed by account that's deactivated", Request: makePostRequestWithPath("test", deactivatedJWSBody), WantErrType: berrors.Unauthorized, WantErrDetail: "Account is not valid, has status \"deactivated\"", WantStatType: "JWSKeyIDAccountInvalid", }, { Name: "Valid JWS for account", Request: makePostRequestWithPath("test", validJWSBody), WantPayload: `{"test":"passed"}`, WantAcct: &validAccount, WantJWS: validJWS, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { wfe.stats.joseErrorCount.Reset() inputLogEvent := newRequestEvent() gotPayload, gotJWS, gotAcct, gotErr := wfe.validPOSTForAccount(tc.Request, context.Background(), inputLogEvent) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("validPOSTForAccount(%#v) = %#v, want nil", tc.Request, gotErr) } if string(gotPayload) != tc.WantPayload { t.Fatalf("validPOSTForAccount(%#v) = %q, want %q", tc.Request, string(gotPayload), tc.WantPayload) } test.AssertMarshaledEquals(t, gotJWS, tc.WantJWS) test.AssertMarshaledEquals(t, gotAcct, tc.WantAcct) } else { berr, ok := gotErr.(*berrors.BoulderError) if !ok { t.Fatalf("validPOSTForAccount(%#v) returned %T, want BoulderError", tc.Request, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("validPOSTForAccount(%#v) = %#v, want %#v", tc.Request, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("validPOSTForAccount(%#v) = %q, want %q", tc.Request, berr.Detail, tc.WantErrDetail) } test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.WantStatType}, 1) } }) } } // TestValidPOSTAsGETForAccount tests POST-as-GET processing. Because // wfe.validPOSTAsGETForAccount calls `wfe.validPOSTForAccount` to do all // processing except the empty body test we do not duplicate the // `TestValidPOSTForAccount` testcases here. func TestValidPOSTAsGETForAccount(t *testing.T) { wfe, _, signer := setupWFE(t) // an invalid POST-as-GET request contains a non-empty payload. In this case // we test with the empty JSON payload ("{}") _, _, invalidPayloadRequest := signer.byKeyID(1, nil, "http://localhost/test", "{}") // a valid POST-as-GET request contains an empty payload. _, _, validRequest := signer.byKeyID(1, nil, "http://localhost/test", "") testCases := []struct { Name string Request *http.Request WantErrType berrors.ErrorType WantErrDetail string WantLogEvent web.RequestEvent }{ { Name: "Non-empty JWS payload", Request: makePostRequestWithPath("test", invalidPayloadRequest), WantErrType: berrors.Malformed, WantErrDetail: "POST-as-GET requests must have an empty payload", WantLogEvent: web.RequestEvent{}, }, { Name: "Valid POST-as-GET", Request: makePostRequestWithPath("test", validRequest), WantLogEvent: web.RequestEvent{ Method: "POST-as-GET", }, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { ev := newRequestEvent() _, gotErr := wfe.validPOSTAsGETForAccount(tc.Request, context.Background(), ev) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("validPOSTAsGETForAccount(%#v) = %#v, want nil", tc.Request, gotErr) } } else { berr, ok := gotErr.(*berrors.BoulderError) if !ok { t.Fatalf("validPOSTAsGETForAccount(%#v) returned %T, want BoulderError", tc.Request, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("validPOSTAsGETForAccount(%#v) = %#v, want %#v", tc.Request, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("validPOSTAsGETForAccount(%#v) = %q, want %q", tc.Request, berr.Detail, tc.WantErrDetail) } } test.AssertMarshaledEquals(t, *ev, tc.WantLogEvent) }) } } type mockSADifferentStoredKey struct { sapb.StorageAuthorityReadOnlyClient } // mockSADifferentStoredKey has a GetRegistration that will always return an // account with the test 2 key, no matter the provided ID func (sa mockSADifferentStoredKey) GetRegistration(_ context.Context, _ *sapb.RegistrationID, _ ...grpc.CallOption) (*corepb.Registration, error) { return &corepb.Registration{ Key: []byte(test2KeyPublicJSON), Status: string(core.StatusValid), }, nil } func TestValidPOSTForAccountSwappedKey(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = &mockSADifferentStoredKey{} wfe.accountGetter = wfe.sa event := newRequestEvent() payload := `{"resource":"ima-payload"}` // Sign a request using test1key _, _, body := signer.byKeyID(1, nil, "http://localhost:4001/test", payload) request := makePostRequestWithPath("test", body) // Ensure that ValidPOSTForAccount produces an error since the // mockSADifferentStoredKey will return a different key than the one we used to // sign the request _, _, _, err := wfe.validPOSTForAccount(request, ctx, event) test.AssertError(t, err, "No error returned for request signed by wrong key") test.AssertErrorIs(t, err, berrors.Malformed) test.AssertContains(t, err.Error(), "JWS verification error") } func TestValidSelfAuthenticatedPOSTGoodKeyErrors(t *testing.T) { wfe, _, signer := setupWFE(t) timeoutErrCheckFunc := func(ctx context.Context, keyHash []byte) (bool, error) { return false, context.DeadlineExceeded } kp, err := goodkey.NewPolicy(nil, timeoutErrCheckFunc) test.AssertNotError(t, err, "making key policy") wfe.keyPolicy = kp _, _, validJWSBody := signer.embeddedJWK(nil, "http://localhost/test", `{"test":"passed"}`) request := makePostRequestWithPath("test", validJWSBody) _, _, err = wfe.validSelfAuthenticatedPOST(context.Background(), request) test.AssertErrorIs(t, err, berrors.InternalServer) badKeyCheckFunc := func(ctx context.Context, keyHash []byte) (bool, error) { return false, fmt.Errorf("oh no: %w", goodkey.ErrBadKey) } kp, err = goodkey.NewPolicy(nil, badKeyCheckFunc) test.AssertNotError(t, err, "making key policy") wfe.keyPolicy = kp _, _, validJWSBody = signer.embeddedJWK(nil, "http://localhost/test", `{"test":"passed"}`) request = makePostRequestWithPath("test", validJWSBody) _, _, err = wfe.validSelfAuthenticatedPOST(context.Background(), request) test.AssertErrorIs(t, err, berrors.BadPublicKey) } func TestValidSelfAuthenticatedPOST(t *testing.T) { wfe, _, signer := setupWFE(t) _, validKey, validJWSBody := signer.embeddedJWK(nil, "http://localhost/test", `{"test":"passed"}`) _, _, keyIDJWSBody := signer.byKeyID(1, nil, "http://localhost/test", `{"test":"passed"}`) testCases := []struct { Name string Request *http.Request WantPayload string WantJWK *jose.JSONWebKey WantErrType berrors.ErrorType WantErrDetail string WantStatType string }{ { Name: "Invalid JWS", Request: makePostRequestWithPath("test", "foo"), WantErrType: berrors.Malformed, WantErrDetail: "Parse error reading JWS", WantStatType: "JWSUnmarshalFailed", }, { Name: "JWS with key ID", Request: makePostRequestWithPath("test", keyIDJWSBody), WantErrType: berrors.Malformed, WantErrDetail: "No embedded JWK in JWS header", WantStatType: "JWSAuthTypeWrong", }, { Name: "Valid JWS", Request: makePostRequestWithPath("test", validJWSBody), WantPayload: `{"test":"passed"}`, WantJWK: validKey, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { wfe.stats.joseErrorCount.Reset() gotPayload, gotJWK, gotErr := wfe.validSelfAuthenticatedPOST(context.Background(), tc.Request) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("validSelfAuthenticatedPOST(%#v) = %#v, want nil", tc.Request, gotErr) } if string(gotPayload) != tc.WantPayload { t.Fatalf("validSelfAuthenticatedPOST(%#v) = %q, want %q", tc.Request, string(gotPayload), tc.WantPayload) } gotThumb, _ := gotJWK.Thumbprint(crypto.SHA256) wantThumb, _ := tc.WantJWK.Thumbprint(crypto.SHA256) if !slices.Equal(gotThumb, wantThumb) { t.Fatalf("validSelfAuthenticatedPOST(%#v) = %#v, want %#v", tc.Request, gotThumb, wantThumb) } } else { berr, ok := gotErr.(*berrors.BoulderError) if !ok { t.Fatalf("validSelfAuthenticatedPOST(%#v) returned %T, want BoulderError", tc.Request, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("validSelfAuthenticatedPOST(%#v) = %#v, want %#v", tc.Request, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("validSelfAuthenticatedPOST(%#v) = %q, want %q", tc.Request, berr.Detail, tc.WantErrDetail) } test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.WantStatType}, 1) } }) } } func TestMatchJWSURLs(t *testing.T) { wfe, _, signer := setupWFE(t) noURLJWS, _, _ := signer.embeddedJWK(nil, "", "") urlAJWS, _, _ := signer.embeddedJWK(nil, "example.com", "") urlBJWS, _, _ := signer.embeddedJWK(nil, "example.org", "") testCases := []struct { Name string Outer *jose.JSONWebSignature Inner *jose.JSONWebSignature WantErrType berrors.ErrorType WantErrDetail string WantStatType string }{ { Name: "Outer JWS without URL", Outer: noURLJWS, Inner: urlAJWS, WantErrType: berrors.Malformed, WantErrDetail: "Outer JWS header parameter 'url' required", WantStatType: "KeyRolloverOuterJWSNoURL", }, { Name: "Inner JWS without URL", Outer: urlAJWS, Inner: noURLJWS, WantErrType: berrors.Malformed, WantErrDetail: "Inner JWS header parameter 'url' required", WantStatType: "KeyRolloverInnerJWSNoURL", }, { Name: "Inner and outer JWS without URL", Outer: noURLJWS, Inner: noURLJWS, WantErrType: berrors.Malformed, WantErrDetail: "Outer JWS header parameter 'url' required", WantStatType: "KeyRolloverOuterJWSNoURL", }, { Name: "Mismatched inner and outer JWS URLs", Outer: urlAJWS, Inner: urlBJWS, WantErrType: berrors.Malformed, WantErrDetail: "Outer JWS 'url' value \"example.com\" does not match inner JWS 'url' value \"example.org\"", WantStatType: "KeyRolloverMismatchedURLs", }, { Name: "Matching inner and outer JWS URLs", Outer: urlAJWS, Inner: urlAJWS, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { wfe.stats.joseErrorCount.Reset() outer := tc.Outer.Signatures[0].Header inner := tc.Inner.Signatures[0].Header gotErr := wfe.matchJWSURLs(outer, inner) if tc.WantErrDetail == "" { if gotErr != nil { t.Fatalf("matchJWSURLs(%#v, %#v) = %#v, want nil", outer, inner, gotErr) } } else { berr, ok := gotErr.(*berrors.BoulderError) if !ok { t.Fatalf("matchJWSURLs(%#v, %#v) returned %T, want BoulderError", outer, inner, gotErr) } if berr.Type != tc.WantErrType { t.Errorf("matchJWSURLs(%#v, %#v) = %#v, want %#v", outer, inner, berr.Type, tc.WantErrType) } if !strings.Contains(berr.Detail, tc.WantErrDetail) { t.Errorf("matchJWSURLs(%#v, %#v) = %q, want %q", outer, inner, berr.Detail, tc.WantErrDetail) } test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.WantStatType}, 1) } }) } }