package wfe2 import ( "bytes" "context" "crypto" "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "io" "math/big" "net/http" "net/http/httptest" "net/url" "os" "sort" "strconv" "strings" "testing" "time" "github.com/jmhodges/clock" "github.com/prometheus/client_golang/prometheus" "golang.org/x/crypto/ocsp" "google.golang.org/grpc" "google.golang.org/protobuf/types/known/emptypb" "gopkg.in/go-jose/go-jose.v2" capb "github.com/letsencrypt/boulder/ca/proto" "github.com/letsencrypt/boulder/core" corepb "github.com/letsencrypt/boulder/core/proto" berrors "github.com/letsencrypt/boulder/errors" "github.com/letsencrypt/boulder/features" "github.com/letsencrypt/boulder/goodkey" bgrpc "github.com/letsencrypt/boulder/grpc" "github.com/letsencrypt/boulder/identifier" "github.com/letsencrypt/boulder/issuance" blog "github.com/letsencrypt/boulder/log" "github.com/letsencrypt/boulder/metrics" "github.com/letsencrypt/boulder/mocks" "github.com/letsencrypt/boulder/must" "github.com/letsencrypt/boulder/nonce" noncepb "github.com/letsencrypt/boulder/nonce/proto" "github.com/letsencrypt/boulder/probs" rapb "github.com/letsencrypt/boulder/ra/proto" "github.com/letsencrypt/boulder/revocation" sapb "github.com/letsencrypt/boulder/sa/proto" "github.com/letsencrypt/boulder/test" inmemnonce "github.com/letsencrypt/boulder/test/inmem/nonce" "github.com/letsencrypt/boulder/web" ) const ( agreementURL = "http://example.invalid/terms" test1KeyPublicJSON = ` { "kty":"RSA", "n":"yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", "e":"AQAB" }` test1KeyPrivatePEM = ` -----BEGIN RSA PRIVATE KEY----- MIIEowIBAAKCAQEAyNWVhtYEKJR21y9xsHV+PD/bYwbXSeNuFal46xYxVfRL5mqh a7vttvjB/vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K/klBYN8oYvTwwmeSkAz 6ut7ZxPv+nZaT5TJhGk0NT2kh/zSpdriEJ/3vW+mqxYbbBmpvHqsa1/zx9fSuHYc tAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV+mzfMyboQjujPh7aNJxAWS q4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF+w8hOTI3XXohUdu 29Se26k2B0PolDSuj0GIQU6+W9TdLXSjBb2SpQIDAQABAoIBAHw58SXYV/Yp72Cn jjFSW+U0sqWMY7rmnP91NsBjl9zNIe3C41pagm39bTIjB2vkBNR8ZRG7pDEB/QAc Cn9Keo094+lmTArjL407ien7Ld+koW7YS8TyKADYikZo0vAK3qOy14JfQNiFAF9r Bw61hG5/E58cK5YwQZe+YcyBK6/erM8fLrJEyw4CV49wWdq/QqmNYU1dx4OExAkl KMfvYXpjzpvyyTnZuS4RONfHsO8+JTyJVm+lUv2x+bTce6R4W++UhQY38HakJ0x3 XRfXooRv1Bletu5OFlpXfTSGz/5gqsfemLSr5UHncsCcFMgoFBsk2t/5BVukBgC7 PnHrAjkCgYEA887PRr7zu3OnaXKxylW5U5t4LzdMQLpslVW7cLPD4Y08Rye6fF5s O/jK1DNFXIoUB7iS30qR7HtaOnveW6H8/kTmMv/YAhLO7PAbRPCKxxcKtniEmP1x ADH0tF2g5uHB/zeZhCo9qJiF0QaJynvSyvSyJFmY6lLvYZsAW+C+PesCgYEA0uCi Q8rXLzLpfH2NKlLwlJTi5JjE+xjbabgja0YySwsKzSlmvYJqdnE2Xk+FHj7TCnSK KUzQKR7+rEk5flwEAf+aCCNh3W4+Hp9MmrdAcCn8ZsKmEW/o7oDzwiAkRCmLw/ck RSFJZpvFoxEg15riT37EjOJ4LBZ6SwedsoGA/a8CgYEA2Ve4sdGSR73/NOKZGc23 q4/B4R2DrYRDPhEySnMGoPCeFrSU6z/lbsUIU4jtQWSaHJPu4n2AfncsZUx9WeSb OzTCnh4zOw33R4N4W8mvfXHODAJ9+kCc1tax1YRN5uTEYzb2dLqPQtfNGxygA1DF BkaC9CKnTeTnH3TlKgK8tUcCgYB7J1lcgh+9ntwhKinBKAL8ox8HJfkUM+YgDbwR sEM69E3wl1c7IekPFvsLhSFXEpWpq3nsuMFw4nsVHwaGtzJYAHByhEdpTDLXK21P heoKF1sioFbgJB1C/Ohe3OqRLDpFzhXOkawOUrbPjvdBM2Erz/r11GUeSlpNazs7 vsoYXQKBgFwFM1IHmqOf8a2wEFa/a++2y/WT7ZG9nNw1W36S3P04K4lGRNRS2Y/S snYiqxD9nL7pVqQP2Qbqbn0yD6d3G5/7r86F7Wu2pihM8g6oyMZ3qZvvRIBvKfWo eROL1ve1vmQF3kjrMPhhK2kr6qdWnTE5XlPllVSZFQenSTzj98AO -----END RSA PRIVATE KEY----- ` test2KeyPublicJSON = `{ "kty":"RSA", "n":"qnARLrT7Xz4gRcKyLdydmCr-ey9OuPImX4X40thk3on26FkMznR3fRjs66eLK7mmPcBZ6uOJseURU6wAaZNmemoYx1dMvqvWWIyiQleHSD7Q8vBrhR6uIoO4jAzJZR-ChzZuSDt7iHN-3xUVspu5XGwXU_MVJZshTwp4TaFx5elHIT_ObnTvTOU3Xhish07AbgZKmWsVbXh5s-CrIicU4OexJPgunWZ_YJJueOKmTvnLlTV4MzKR2oZlBKZ27S0-SfdV_QDx_ydle5oMAyKVtlAV35cyPMIsYNwgUGBCdY_2Uzi5eX0lTc7MPRwz6qR1kip-i59VcGcUQgqHV6Fyqw", "e":"AQAB" }` test2KeyPrivatePEM = ` -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEAqnARLrT7Xz4gRcKyLdydmCr+ey9OuPImX4X40thk3on26FkM znR3fRjs66eLK7mmPcBZ6uOJseURU6wAaZNmemoYx1dMvqvWWIyiQleHSD7Q8vBr hR6uIoO4jAzJZR+ChzZuSDt7iHN+3xUVspu5XGwXU/MVJZshTwp4TaFx5elHIT/O bnTvTOU3Xhish07AbgZKmWsVbXh5s+CrIicU4OexJPgunWZ/YJJueOKmTvnLlTV4 MzKR2oZlBKZ27S0+SfdV/QDx/ydle5oMAyKVtlAV35cyPMIsYNwgUGBCdY/2Uzi5 eX0lTc7MPRwz6qR1kip+i59VcGcUQgqHV6FyqwIDAQABAoIBAG5m8Xpj2YC0aYtG tsxmX9812mpJFqFOmfS+f5N0gMJ2c+3F4TnKz6vE/ZMYkFnehAT0GErC4WrOiw68 F/hLdtJM74gQ0LGh9dKeJmz67bKqngcAHWW5nerVkDGIBtzuMEsNwxofDcIxrjkr G0b7AHMRwXqrt0MI3eapTYxby7+08Yxm40mxpSsW87FSaI61LDxUDpeVkn7kolSN WifVat7CpZb/D2BfGAQDxiU79YzgztpKhbynPdGc/OyyU+CNgk9S5MgUX2m9Elh3 aXrWh2bT2xzF+3KgZdNkJQcdIYVoGq/YRBxlGXPYcG4Do3xKhBmH79Io2BizevZv nHkbUGECgYEAydjb4rl7wYrElDqAYpoVwKDCZAgC6o3AKSGXfPX1Jd2CXgGR5Hkl ywP0jdSLbn2v/jgKQSAdRbYuEiP7VdroMb5M6BkBhSY619cH8etoRoLzFo1GxcE8 Y7B598VXMq8TT+TQqw/XRvM18aL3YDZ3LSsR7Gl2jF/sl6VwQAaZToUCgYEA2Cn4 fG58ME+M4IzlZLgAIJ83PlLb9ip6MeHEhUq2Dd0In89nss7Acu0IVg8ES88glJZy 4SjDLGSiuQuoQVo9UBq/E5YghdMJFp5ovwVfEaJ+ruWqOeujvWzzzPVyIWSLXRQa N4kedtfrlqldMIXywxVru66Q1NOGvhDHm/Q8+28CgYEAkhLCbn3VNed7A9qidrkT 7OdqRoIVujEDU8DfpKtK0jBP3EA+mJ2j4Bvoq4uZrEiBSPS9VwwqovyIstAfX66g Qv95IK6YDwfvpawUL9sxB3ZU/YkYIp0JWwun+Mtzo1ZYH4V0DZfVL59q9of9hj9k V+fHfNOF22jAC67KYUtlPxECgYEAwF6hj4L3rDqvQYrB/p8tJdrrW+B7dhgZRNkJ fiGd4LqLGUWHoH4UkHJXT9bvWNPMx88YDz6qapBoq8svAnHfTLFwyGp7KP1FAkcZ Kp4KG/SDTvx+QCtvPX1/fjAUUJlc2QmxxyiU3uiK9Tpl/2/FOk2O4aiZpX1VVUIz kZuKxasCgYBiVRkEBk2W4Ia0B7dDkr2VBrz4m23Y7B9cQLpNAapiijz/0uHrrCl8 TkLlEeVOuQfxTadw05gzKX0jKkMC4igGxvEeilYc6NR6a4nvRulG84Q8VV9Sy9Ie wk6Oiadty3eQqSBJv0HnpmiEdQVffIK5Pg4M8Dd+aOBnEkbopAJOuA== -----END RSA PRIVATE KEY----- ` test3KeyPrivatePEM = ` -----BEGIN RSA PRIVATE KEY----- MIIEpAIBAAKCAQEAuTQER6vUA1RDixS8xsfCRiKUNGRzzyIK0MhbS2biClShbb0h Sx2mPP7gBvis2lizZ9r+y9hL57kNQoYCKndOBg0FYsHzrQ3O9AcoV1z2Mq+XhHZb FrVYaXI0M3oY9BJCWog0dyi3XC0x8AxC1npd1U61cToHx+3uSvgZOuQA5ffEn5L3 8Dz1Ti7OV3E4XahnRJvejadUmTkki7phLBUXm5MnnyFm0CPpf6ApV7zhLjN5W+nV 0WL17o7v8aDgV/t9nIdi1Y26c3PlCEtiVHZcebDH5F1Deta3oLLg9+g6rWnTqPbY 3knffhp4m0scLD6e33k8MtzxDX/D7vHsg0/X1wIDAQABAoIBAQCnFJpX3lhiuH5G 1uqHmmdVxpRVv9oKn/eJ63cRSzvZfgg0bE/A6Hq0xGtvXqDySttvck4zsGqqHnQr 86G4lfE53D1jnv4qvS5bUKnARwmFKIxU4EHE9s1QM8uMNTaV2nMqIX7TkVP6QHuw yB70R2inq15dS7EBWVGFKNX6HwAAdj8pFuF6o2vIwmAfee20aFzpWWf81jOH9Ai6 hyJyV3NqrU1JzIwlXaeX67R1VroFdhN/lapp+2b0ZEcJJtFlcYFl99NjkQeVZyik izNv0GZZNWizc57wU0/8cv+jQ2f26ltvyrPz3QNK61bFfzy+/tfMvLq7sdCmztKJ tMxCBJOBAoGBAPKnIVQIS2nTvC/qZ8ajw1FP1rkvYblIiixegjgfFhM32HehQ+nu 3TELi3I3LngLYi9o6YSqtNBmdBJB+DUAzIXp0TdOihOweGiv5dAEWwY9rjCzMT5S GP7dCWiJwoMUHrOs1Po3dwcjj/YsoAW+FC0jSvach2Ln2CvPgr5FP0ARAoGBAMNj 64qUCzgeXiSyPKK69bCCGtHlTYUndwHQAZmABjbmxAXZNYgp/kBezFpKOwmICE8R kK8YALRrL0VWXl/yj85b0HAZGkquNFHPUDd1e6iiP5TrY+Hy4oqtlYApjH6f85CE lWjQ1iyUL7aT6fcSgzq65ZWD2hUzvNtWbTt6zQFnAoGAWS/EuDY0QblpOdNWQVR/ vasyqO4ZZRiccKJsCmSioH2uOoozhBAfjJ9JqblOgyDr/bD546E6xD5j+zH0IMci ZTYDh+h+J659Ez1Topl3O1wAYjX6q4VRWpuzkZDQxYznm/KydSVdwmn3x+uvBW1P zSdjrjDqMhg1BCVJUNXy4YECgYEAjX1z+dwO68qB3gz7/9NnSzRL+6cTJdNYSIW6 QtAEsAkX9iw+qaXPKgn77X5HljVd3vQXU9QL3pqnloxetxhNrt+p5yMmeOIBnSSF MEPxEkK7zDlRETPzfP0Kf86WoLNviz2XfFmOXqXIj2w5RuOvB/6DdmwOpr/aiPLj EulwPw0CgYAMSzsWOt6vU+y/G5NyhUCHvY50TdnGOj2btBk9rYVwWGWxCpg2QF0R pcKXgGzXEVZKFAqB8V1c/mmCo8ojPgmqGM+GzX2Bj4seVBW7PsTeZUjrHpADshjV F7o5b7y92NlxO5kwQzRKEAhwS5PbKJdx90iCuG+JlI1YgWlA1VcJMw== -----END RSA PRIVATE KEY----- ` testE1KeyPrivatePEM = ` -----BEGIN EC PRIVATE KEY----- MHcCAQEEIH+p32RUnqT/iICBEGKrLIWFcyButv0S0lU/BLPOyHn2oAoGCCqGSM49 AwEHoUQDQgAEFwvSZpu06i3frSk/mz9HcD9nETn4wf3mQ+zDtG21GapLytH7R1Zr ycBzDV9u6cX9qNLc9Bn5DAumz7Zp2AuA+Q== -----END EC PRIVATE KEY----- ` testE2KeyPrivatePEM = ` -----BEGIN EC PRIVATE KEY----- MHcCAQEEIFRcPxQ989AY6se2RyIoF1ll9O6gHev4oY15SWJ+Jf5eoAoGCCqGSM49 AwEHoUQDQgAES8FOmrZ3ywj4yyFqt0etAD90U+EnkNaOBSLfQmf7pNi8y+kPKoUN EeMZ9nWyIM6bktLrE11HnFOnKhAYsM5fZA== -----END EC PRIVATE KEY-----` ) type MockRegistrationAuthority struct { lastRevocationReason revocation.Reason } func (ra *MockRegistrationAuthority) NewRegistration(ctx context.Context, in *corepb.Registration, _ ...grpc.CallOption) (*corepb.Registration, error) { in.Id = 1 in.CreatedAt = time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC).UnixNano() return in, nil } func (ra *MockRegistrationAuthority) UpdateRegistration(ctx context.Context, in *rapb.UpdateRegistrationRequest, _ ...grpc.CallOption) (*corepb.Registration, error) { if !bytes.Equal(in.Base.Key, in.Update.Key) { in.Base.Key = in.Update.Key } return in.Base, nil } func (ra *MockRegistrationAuthority) PerformValidation(context.Context, *rapb.PerformValidationRequest, ...grpc.CallOption) (*corepb.Authorization, error) { return &corepb.Authorization{}, nil } func (ra *MockRegistrationAuthority) RevokeCertByApplicant(ctx context.Context, in *rapb.RevokeCertByApplicantRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { ra.lastRevocationReason = revocation.Reason(in.Code) return &emptypb.Empty{}, nil } func (ra *MockRegistrationAuthority) RevokeCertByKey(ctx context.Context, in *rapb.RevokeCertByKeyRequest, _ ...grpc.CallOption) (*emptypb.Empty, error) { ra.lastRevocationReason = revocation.Reason(ocsp.KeyCompromise) return &emptypb.Empty{}, nil } func (ra *MockRegistrationAuthority) GenerateOCSP(ctx context.Context, req *rapb.GenerateOCSPRequest, _ ...grpc.CallOption) (*capb.OCSPResponse, error) { return nil, nil } func (ra *MockRegistrationAuthority) AdministrativelyRevokeCertificate(context.Context, *rapb.AdministrativelyRevokeCertificateRequest, ...grpc.CallOption) (*emptypb.Empty, error) { return &emptypb.Empty{}, nil } func (ra *MockRegistrationAuthority) OnValidationUpdate(context.Context, core.Authorization, ...grpc.CallOption) error { return nil } func (ra *MockRegistrationAuthority) DeactivateAuthorization(context.Context, *corepb.Authorization, ...grpc.CallOption) (*emptypb.Empty, error) { return &emptypb.Empty{}, nil } func (ra *MockRegistrationAuthority) DeactivateRegistration(context.Context, *corepb.Registration, ...grpc.CallOption) (*emptypb.Empty, error) { return &emptypb.Empty{}, nil } func (ra *MockRegistrationAuthority) NewOrder(ctx context.Context, in *rapb.NewOrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) { return &corepb.Order{ Id: 1, RegistrationID: in.RegistrationID, Created: time.Date(2021, 1, 1, 1, 1, 1, 0, time.UTC).UnixNano(), Expires: time.Date(2021, 2, 1, 1, 1, 1, 0, time.UTC).UnixNano(), Names: in.Names, Status: string(core.StatusPending), V2Authorizations: []int64{1}, }, nil } func (ra *MockRegistrationAuthority) FinalizeOrder(ctx context.Context, in *rapb.FinalizeOrderRequest, _ ...grpc.CallOption) (*corepb.Order, error) { in.Order.Status = string(core.StatusProcessing) return in.Order, nil } func makeBody(s string) io.ReadCloser { return io.NopCloser(strings.NewReader(s)) } // loadKey loads a private key from PEM/DER-encoded data and returns // a `crypto.Signer`. func loadKey(t *testing.T, keyBytes []byte) crypto.Signer { // pem.Decode does not return an error as its 2nd arg, but instead the "rest" // that was leftover from parsing the PEM block. We only care if the decoded // PEM block was empty for this test function. block, _ := pem.Decode(keyBytes) if block == nil { t.Fatal("Unable to decode private key PEM bytes") } // Try decoding as an RSA private key if rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { return rsaKey } // Try decoding as a PKCS8 private key if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil { // Determine the key's true type and return it as a crypto.Signer switch k := key.(type) { case *rsa.PrivateKey: return k case *ecdsa.PrivateKey: return k } } // Try as an ECDSA private key if ecdsaKey, err := x509.ParseECPrivateKey(block.Bytes); err == nil { return ecdsaKey } // Nothing worked! Fail hard. t.Fatalf("Unable to decode private key PEM bytes") // NOOP - the t.Fatal() call will abort before this return return nil } var testKeyPolicy = goodkey.KeyPolicy{ AllowRSA: true, AllowECDSANISTP256: true, AllowECDSANISTP384: true, } var ctx = context.Background() func setupWFE(t *testing.T) (WebFrontEndImpl, clock.FakeClock, requestSigner) { features.Reset() fc := clock.NewFake() stats := metrics.NoopRegisterer certChains := map[issuance.IssuerNameID][][]byte{} issuerCertificates := map[issuance.IssuerNameID]*issuance.Certificate{} for _, files := range [][]string{ { "../test/hierarchy/int-r3.cert.pem", "../test/hierarchy/root-x1.cert.pem", }, { "../test/hierarchy/int-r3-cross.cert.pem", "../test/hierarchy/root-dst.cert.pem", }, { "../test/hierarchy/int-e1.cert.pem", "../test/hierarchy/root-x2.cert.pem", }, { "../test/hierarchy/int-e1.cert.pem", "../test/hierarchy/root-x2-cross.cert.pem", "../test/hierarchy/root-x1-cross.cert.pem", "../test/hierarchy/root-dst.cert.pem", }, } { certs, err := issuance.LoadChain(files) test.AssertNotError(t, err, "Unable to load chain") var buf bytes.Buffer for _, cert := range certs { buf.Write([]byte("\n")) buf.Write(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: cert.Raw})) } id := certs[0].NameID() certChains[id] = append(certChains[id], buf.Bytes()) issuerCertificates[id] = certs[0] } mockSA := mocks.NewStorageAuthorityReadOnly(fc) noncePrefix := "mlem" nonceService, err := nonce.NewNonceService(metrics.NoopRegisterer, 100, noncePrefix) test.AssertNotError(t, err, "making nonceService") nonceGRPCService := &inmemnonce.Service{ NonceService: nonceService, } wfe, err := NewWebFrontEndImpl( stats, fc, testKeyPolicy, certChains, issuerCertificates, blog.NewMock(), 10*time.Second, 10*time.Second, 30*24*time.Hour, 7*24*time.Hour, &MockRegistrationAuthority{}, mockSA, nonceGRPCService, map[string]nonce.Redeemer{noncePrefix: nonceGRPCService}, nonceGRPCService, "", mockSA) test.AssertNotError(t, err, "Unable to create WFE") wfe.SubscriberAgreementURL = agreementURL return wfe, fc, requestSigner{t, nonceGRPCService.AsSource()} } // makePostRequestWithPath creates an http.Request for localhost with method // POST, the provided body, and the correct Content-Length. The path provided // will be parsed as a URL and used to populate the request URL and RequestURI func makePostRequestWithPath(path string, body string) *http.Request { request := &http.Request{ Method: "POST", RemoteAddr: "1.1.1.1:7882", Header: map[string][]string{ "Content-Length": {strconv.Itoa(len(body))}, "Content-Type": {expectedJWSContentType}, }, Body: makeBody(body), Host: "localhost", } url := mustParseURL(path) request.URL = url request.RequestURI = url.Path return request } // signAndPost constructs a JWS signed by the account with ID 1, over the given // payload, with the protected URL set to the provided signedURL. An HTTP // request constructed to the provided path with the encoded JWS body as the // POST body is returned. func signAndPost(signer requestSigner, path, signedURL, payload string) *http.Request { _, _, body := signer.byKeyID(1, nil, signedURL, payload) return makePostRequestWithPath(path, body) } func mustParseURL(s string) *url.URL { return must.Do(url.Parse(s)) } func sortHeader(s string) string { a := strings.Split(s, ", ") sort.Strings(a) return strings.Join(a, ", ") } func addHeadIfGet(s []string) []string { for _, a := range s { if a == "GET" { return append(s, "HEAD") } } return s } func TestHandleFunc(t *testing.T) { wfe, _, _ := setupWFE(t) var mux *http.ServeMux var rw *httptest.ResponseRecorder var stubCalled bool runWrappedHandler := func(req *http.Request, pattern string, allowed ...string) { mux = http.NewServeMux() rw = httptest.NewRecorder() stubCalled = false wfe.HandleFunc(mux, pattern, func(context.Context, *web.RequestEvent, http.ResponseWriter, *http.Request) { stubCalled = true }, allowed...) req.URL = mustParseURL(pattern) mux.ServeHTTP(rw, req) } // Plain requests (no CORS) type testCase struct { allowed []string reqMethod string shouldCallStub bool shouldSucceed bool pattern string } var lastNonce string for _, c := range []testCase{ {[]string{"GET", "POST"}, "GET", true, true, "/test"}, {[]string{"GET", "POST"}, "GET", true, true, newNoncePath}, {[]string{"GET", "POST"}, "POST", true, true, "/test"}, {[]string{"GET"}, "", false, false, "/test"}, {[]string{"GET"}, "POST", false, false, "/test"}, {[]string{"GET"}, "OPTIONS", false, true, "/test"}, {[]string{"GET"}, "MAKE-COFFEE", false, false, "/test"}, // 405, or 418? {[]string{"GET"}, "GET", true, true, directoryPath}, } { runWrappedHandler(&http.Request{Method: c.reqMethod}, c.pattern, c.allowed...) test.AssertEquals(t, stubCalled, c.shouldCallStub) if c.shouldSucceed { test.AssertEquals(t, rw.Code, http.StatusOK) } else { test.AssertEquals(t, rw.Code, http.StatusMethodNotAllowed) test.AssertEquals(t, sortHeader(rw.Header().Get("Allow")), sortHeader(strings.Join(addHeadIfGet(c.allowed), ", "))) test.AssertUnmarshaledEquals(t, rw.Body.String(), `{"type":"`+probs.ErrorNS+`malformed","detail":"Method not allowed","status":405}`) } if c.reqMethod == "GET" && c.pattern != newNoncePath { nonce := rw.Header().Get("Replay-Nonce") test.AssertEquals(t, nonce, "") } else { nonce := rw.Header().Get("Replay-Nonce") test.AssertNotEquals(t, nonce, lastNonce) test.AssertNotEquals(t, nonce, "") lastNonce = nonce } linkHeader := rw.Header().Get("Link") if c.pattern != directoryPath { // If the pattern wasn't the directory there should be a Link header for the index test.AssertEquals(t, linkHeader, `;rel="index"`) } else { // The directory resource shouldn't get a link header test.AssertEquals(t, linkHeader, "") } } // Disallowed method returns error JSON in body runWrappedHandler(&http.Request{Method: "PUT"}, "/test", "GET", "POST") test.AssertEquals(t, rw.Header().Get("Content-Type"), "application/problem+json") test.AssertUnmarshaledEquals(t, rw.Body.String(), `{"type":"`+probs.ErrorNS+`malformed","detail":"Method not allowed","status":405}`) test.AssertEquals(t, sortHeader(rw.Header().Get("Allow")), "GET, HEAD, POST") // Disallowed method special case: response to HEAD has got no body runWrappedHandler(&http.Request{Method: "HEAD"}, "/test", "GET", "POST") test.AssertEquals(t, stubCalled, true) test.AssertEquals(t, rw.Body.String(), "") // HEAD doesn't work with POST-only endpoints runWrappedHandler(&http.Request{Method: "HEAD"}, "/test", "POST") test.AssertEquals(t, stubCalled, false) test.AssertEquals(t, rw.Code, http.StatusMethodNotAllowed) test.AssertEquals(t, rw.Header().Get("Content-Type"), "application/problem+json") test.AssertEquals(t, rw.Header().Get("Allow"), "POST") test.AssertUnmarshaledEquals(t, rw.Body.String(), `{"type":"`+probs.ErrorNS+`malformed","detail":"Method not allowed","status":405}`) wfe.AllowOrigins = []string{"*"} testOrigin := "https://example.com" // CORS "actual" request for disallowed method runWrappedHandler(&http.Request{ Method: "POST", Header: map[string][]string{ "Origin": {testOrigin}, }, }, "/test", "GET") test.AssertEquals(t, stubCalled, false) test.AssertEquals(t, rw.Code, http.StatusMethodNotAllowed) // CORS "actual" request for allowed method runWrappedHandler(&http.Request{ Method: "GET", Header: map[string][]string{ "Origin": {testOrigin}, }, }, "/test", "GET", "POST") test.AssertEquals(t, stubCalled, true) test.AssertEquals(t, rw.Code, http.StatusOK) test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Methods"), "") test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "*") test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "Content-Type") test.AssertEquals(t, sortHeader(rw.Header().Get("Access-Control-Expose-Headers")), "Link, Location, Replay-Nonce") // CORS preflight request for disallowed method runWrappedHandler(&http.Request{ Method: "OPTIONS", Header: map[string][]string{ "Origin": {testOrigin}, "Access-Control-Request-Method": {"POST"}, }, }, "/test", "GET") test.AssertEquals(t, stubCalled, false) test.AssertEquals(t, rw.Code, http.StatusOK) test.AssertEquals(t, rw.Header().Get("Allow"), "GET, HEAD") test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "") test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "") // CORS preflight request for allowed method runWrappedHandler(&http.Request{ Method: "OPTIONS", Header: map[string][]string{ "Origin": {testOrigin}, "Access-Control-Request-Method": {"POST"}, "Access-Control-Request-Headers": {"X-Accept-Header1, X-Accept-Header2", "X-Accept-Header3"}, }, }, "/test", "GET", "POST") test.AssertEquals(t, rw.Code, http.StatusOK) test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "*") test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "Content-Type") test.AssertEquals(t, rw.Header().Get("Access-Control-Max-Age"), "86400") test.AssertEquals(t, sortHeader(rw.Header().Get("Access-Control-Allow-Methods")), "GET, HEAD, POST") test.AssertEquals(t, sortHeader(rw.Header().Get("Access-Control-Expose-Headers")), "Link, Location, Replay-Nonce") // OPTIONS request without an Origin header (i.e., not a CORS // preflight request) runWrappedHandler(&http.Request{ Method: "OPTIONS", Header: map[string][]string{ "Access-Control-Request-Method": {"POST"}, }, }, "/test", "GET", "POST") test.AssertEquals(t, rw.Code, http.StatusOK) test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "") test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "") test.AssertEquals(t, sortHeader(rw.Header().Get("Allow")), "GET, HEAD, POST") // CORS preflight request missing optional Request-Method // header. The "actual" request will be GET. for _, allowedMethod := range []string{"GET", "POST"} { runWrappedHandler(&http.Request{ Method: "OPTIONS", Header: map[string][]string{ "Origin": {testOrigin}, }, }, "/test", allowedMethod) test.AssertEquals(t, rw.Code, http.StatusOK) if allowedMethod == "GET" { test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "*") test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "Content-Type") test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Methods"), "GET, HEAD") } else { test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), "") test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Headers"), "") } } // No CORS headers are given when configuration does not list // "*" or the client-provided origin. for _, wfe.AllowOrigins = range [][]string{ {}, {"http://example.com", "https://other.example"}, {""}, // Invalid origin is never matched } { runWrappedHandler(&http.Request{ Method: "OPTIONS", Header: map[string][]string{ "Origin": {testOrigin}, "Access-Control-Request-Method": {"POST"}, }, }, "/test", "POST") test.AssertEquals(t, rw.Code, http.StatusOK) for _, h := range []string{ "Access-Control-Allow-Methods", "Access-Control-Allow-Origin", "Access-Control-Allow-Headers", "Access-Control-Expose-Headers", "Access-Control-Request-Headers", } { test.AssertEquals(t, rw.Header().Get(h), "") } } // CORS headers are offered when configuration lists "*" or // the client-provided origin. for _, wfe.AllowOrigins = range [][]string{ {testOrigin, "http://example.org", "*"}, {"", "http://example.org", testOrigin}, // Invalid origin is harmless } { runWrappedHandler(&http.Request{ Method: "OPTIONS", Header: map[string][]string{ "Origin": {testOrigin}, "Access-Control-Request-Method": {"POST"}, }, }, "/test", "POST") test.AssertEquals(t, rw.Code, http.StatusOK) test.AssertEquals(t, rw.Header().Get("Access-Control-Allow-Origin"), testOrigin) // http://www.w3.org/TR/cors/ section 6.4: test.AssertEquals(t, rw.Header().Get("Vary"), "Origin") } } func TestPOST404(t *testing.T) { wfe, _, _ := setupWFE(t) responseWriter := httptest.NewRecorder() url, _ := url.Parse("/foobar") wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ Method: "POST", URL: url, }) test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) } func TestIndex(t *testing.T) { wfe, _, _ := setupWFE(t) responseWriter := httptest.NewRecorder() url, _ := url.Parse("/") wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ Method: "GET", URL: url, }) test.AssertEquals(t, responseWriter.Code, http.StatusOK) test.AssertNotEquals(t, responseWriter.Body.String(), "404 page not found\n") test.Assert(t, strings.Contains(responseWriter.Body.String(), directoryPath), "directory path not found") test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "public, max-age=0, no-cache") responseWriter.Body.Reset() responseWriter.Header().Del("Cache-Control") url, _ = url.Parse("/foo") wfe.Index(ctx, newRequestEvent(), responseWriter, &http.Request{ URL: url, }) //test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) test.AssertEquals(t, responseWriter.Body.String(), "404 page not found\n") test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), "") } // randomDirectoryKeyPresent unmarshals the given buf of JSON and returns true // if `randomDirKeyExplanationLink` appears as the value of a key in the directory // object. func randomDirectoryKeyPresent(t *testing.T, buf []byte) bool { var dir map[string]interface{} err := json.Unmarshal(buf, &dir) if err != nil { t.Errorf("Failed to unmarshal directory: %s", err) } for _, v := range dir { if v == randomDirKeyExplanationLink { return true } } return false } type fakeRand struct{} func (fr fakeRand) Read(p []byte) (int, error) { return len(p), nil } func TestDirectory(t *testing.T) { wfe, _, signer := setupWFE(t) mux := wfe.Handler(metrics.NoopRegisterer) core.RandReader = fakeRand{} defer func() { core.RandReader = rand.Reader }() dirURL, _ := url.Parse("/directory") getReq := &http.Request{ Method: http.MethodGet, URL: dirURL, Host: "localhost:4300", } _, _, jwsBody := signer.byKeyID(1, nil, "http://localhost/directory", "") postAsGetReq := makePostRequestWithPath("/directory", jwsBody) testCases := []struct { name string caaIdent string website string expectedJSON string request *http.Request }{ { name: "standard GET, no CAA ident/website meta", request: getReq, expectedJSON: `{ "keyChange": "http://localhost:4300/acme/key-change", "meta": { "termsOfService": "http://example.invalid/terms" }, "newNonce": "http://localhost:4300/acme/new-nonce", "newAccount": "http://localhost:4300/acme/new-acct", "newOrder": "http://localhost:4300/acme/new-order", "revokeCert": "http://localhost:4300/acme/revoke-cert", "AAAAAAAAAAA": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417" }`, }, { name: "standard GET, CAA ident/website meta", caaIdent: "Radiant Lock", website: "zombo.com", request: getReq, expectedJSON: `{ "AAAAAAAAAAA": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417", "keyChange": "http://localhost:4300/acme/key-change", "meta": { "caaIdentities": [ "Radiant Lock" ], "termsOfService": "http://example.invalid/terms", "website": "zombo.com" }, "newAccount": "http://localhost:4300/acme/new-acct", "newNonce": "http://localhost:4300/acme/new-nonce", "newOrder": "http://localhost:4300/acme/new-order", "revokeCert": "http://localhost:4300/acme/revoke-cert" }`, }, { name: "POST-as-GET, CAA ident/website meta", caaIdent: "Radiant Lock", website: "zombo.com", request: postAsGetReq, expectedJSON: `{ "AAAAAAAAAAA": "https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417", "keyChange": "http://localhost/acme/key-change", "meta": { "caaIdentities": [ "Radiant Lock" ], "termsOfService": "http://example.invalid/terms", "website": "zombo.com" }, "newAccount": "http://localhost/acme/new-acct", "newNonce": "http://localhost/acme/new-nonce", "newOrder": "http://localhost/acme/new-order", "revokeCert": "http://localhost/acme/revoke-cert" }`, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Configure a caaIdentity and website for the /directory meta based on the tc wfe.DirectoryCAAIdentity = tc.caaIdent // "Radiant Lock" wfe.DirectoryWebsite = tc.website //"zombo.com" responseWriter := httptest.NewRecorder() // Serve the /directory response for this request into a recorder mux.ServeHTTP(responseWriter, tc.request) // We expect all directory requests to return a json object with a good HTTP status test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") // We expect all requests to return status OK test.AssertEquals(t, responseWriter.Code, http.StatusOK) // The response should match expected test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.expectedJSON) // Check that the random directory key is present test.AssertEquals(t, randomDirectoryKeyPresent(t, responseWriter.Body.Bytes()), true) }) } } func TestRelativeDirectory(t *testing.T) { wfe, _, _ := setupWFE(t) mux := wfe.Handler(metrics.NoopRegisterer) core.RandReader = fakeRand{} defer func() { core.RandReader = rand.Reader }() expectedDirectory := func(hostname string) string { expected := new(bytes.Buffer) fmt.Fprintf(expected, "{") fmt.Fprintf(expected, `"keyChange":"%s/acme/key-change",`, hostname) fmt.Fprintf(expected, `"newNonce":"%s/acme/new-nonce",`, hostname) fmt.Fprintf(expected, `"newAccount":"%s/acme/new-acct",`, hostname) fmt.Fprintf(expected, `"newOrder":"%s/acme/new-order",`, hostname) fmt.Fprintf(expected, `"revokeCert":"%s/acme/revoke-cert",`, hostname) fmt.Fprintf(expected, `"AAAAAAAAAAA":"https://community.letsencrypt.org/t/adding-random-entries-to-the-directory/33417",`) fmt.Fprintf(expected, `"meta":{"termsOfService":"http://example.invalid/terms"}`) fmt.Fprintf(expected, "}") return expected.String() } dirTests := []struct { host string protoHeader string result string }{ // Test '' (No host header) with no proto header {"", "", expectedDirectory("http://localhost")}, // Test localhost:4300 with no proto header {"localhost:4300", "", expectedDirectory("http://localhost:4300")}, // Test 127.0.0.1:4300 with no proto header {"127.0.0.1:4300", "", expectedDirectory("http://127.0.0.1:4300")}, // Test localhost:4300 with HTTP proto header {"localhost:4300", "http", expectedDirectory("http://localhost:4300")}, // Test localhost:4300 with HTTPS proto header {"localhost:4300", "https", expectedDirectory("https://localhost:4300")}, } for _, tt := range dirTests { var headers map[string][]string responseWriter := httptest.NewRecorder() if tt.protoHeader != "" { headers = map[string][]string{ "X-Forwarded-Proto": {tt.protoHeader}, } } mux.ServeHTTP(responseWriter, &http.Request{ Method: "GET", Host: tt.host, URL: mustParseURL(directoryPath), Header: headers, }) test.AssertEquals(t, responseWriter.Header().Get("Content-Type"), "application/json") test.AssertEquals(t, responseWriter.Code, http.StatusOK) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tt.result) } } // TestNonceEndpoint tests requests to the WFE2's new-nonce endpoint func TestNonceEndpoint(t *testing.T) { wfe, _, signer := setupWFE(t) mux := wfe.Handler(metrics.NoopRegisterer) getReq := &http.Request{ Method: http.MethodGet, URL: mustParseURL(newNoncePath), } headReq := &http.Request{ Method: http.MethodHead, URL: mustParseURL(newNoncePath), } _, _, jwsBody := signer.byKeyID(1, nil, fmt.Sprintf("http://localhost%s", newNoncePath), "") postAsGetReq := makePostRequestWithPath(newNoncePath, jwsBody) testCases := []struct { name string request *http.Request expectedStatus int }{ { name: "GET new-nonce request", request: getReq, expectedStatus: http.StatusNoContent, }, { name: "HEAD new-nonce request", request: headReq, expectedStatus: http.StatusOK, }, { name: "POST-as-GET new-nonce request", request: postAsGetReq, expectedStatus: http.StatusOK, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { responseWriter := httptest.NewRecorder() mux.ServeHTTP(responseWriter, tc.request) // The response should have the expected HTTP status code test.AssertEquals(t, responseWriter.Code, tc.expectedStatus) // And the response should contain a valid nonce in the Replay-Nonce header nonce := responseWriter.Header().Get("Replay-Nonce") redeemResp, err := wfe.rnc.Redeem(context.Background(), &noncepb.NonceMessage{Nonce: nonce}) test.AssertNotError(t, err, "redeeming nonce") test.AssertEquals(t, redeemResp.Valid, true) // The server MUST include a Cache-Control header field with the "no-store" // directive in responses for the newNonce resource, in order to prevent // caching of this resource. cacheControl := responseWriter.Header().Get("Cache-Control") test.AssertEquals(t, cacheControl, "no-store") }) } } func TestHTTPMethods(t *testing.T) { wfe, _, _ := setupWFE(t) mux := wfe.Handler(metrics.NoopRegisterer) // NOTE: Boulder's muxer treats HEAD as implicitly allowed if GET is specified // so we include both here in `getOnly` getOnly := map[string]bool{http.MethodGet: true, http.MethodHead: true} postOnly := map[string]bool{http.MethodPost: true} getOrPost := map[string]bool{http.MethodGet: true, http.MethodHead: true, http.MethodPost: true} testCases := []struct { Name string Path string Allowed map[string]bool }{ { Name: "Index path should be GET only", Path: "/", Allowed: getOnly, }, { Name: "Directory path should be GET or POST only", Path: directoryPath, Allowed: getOrPost, }, { Name: "NewAcct path should be POST only", Path: newAcctPath, Allowed: postOnly, }, { Name: "Acct path should be POST only", Path: acctPath, Allowed: postOnly, }, // TODO(@cpu): Remove GET authz support, support only POST-as-GET { Name: "Authz path should be GET or POST only", Path: authzPath, Allowed: getOrPost, }, // TODO(@cpu): Remove GET challenge support, support only POST-as-GET { Name: "Challenge path should be GET or POST only", Path: challengePath, Allowed: getOrPost, }, // TODO(@cpu): Remove GET certificate support, support only POST-as-GET { Name: "Certificate path should be GET or POST only", Path: certPath, Allowed: getOrPost, }, { Name: "RevokeCert path should be POST only", Path: revokeCertPath, Allowed: postOnly, }, { Name: "Build ID path should be GET only", Path: buildIDPath, Allowed: getOnly, }, { Name: "Rollover path should be POST only", Path: rolloverPath, Allowed: postOnly, }, { Name: "New order path should be POST only", Path: newOrderPath, Allowed: postOnly, }, // TODO(@cpu): Remove GET order support, support only POST-as-GET { Name: "Order path should be GET or POST only", Path: orderPath, Allowed: getOrPost, }, { Name: "Nonce path should be GET or POST only", Path: newNoncePath, Allowed: getOrPost, }, } // NOTE: We omit http.MethodOptions because all requests with this method are // redirected to a special endpoint for CORS headers allMethods := []string{ http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodConnect, http.MethodTrace, } responseWriter := httptest.NewRecorder() for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { // For every possible HTTP method check what the mux serves for the test // case path for _, method := range allMethods { responseWriter.Body.Reset() mux.ServeHTTP(responseWriter, &http.Request{ Method: method, URL: mustParseURL(tc.Path), }) // If the method isn't one that is intended to be allowed by the path, // check that the response was the not allowed response if _, ok := tc.Allowed[method]; !ok { var prob probs.ProblemDetails // Unmarshal the body into a problem body := responseWriter.Body.String() err := json.Unmarshal([]byte(body), &prob) test.AssertNotError(t, err, fmt.Sprintf("Error unmarshalling resp body: %q", body)) // TODO(@cpu): It seems like the mux should be returning // http.StatusMethodNotAllowed here, but instead it returns StatusOK // with a problem that has a StatusMethodNotAllowed HTTPStatus. Is // this a bug? test.AssertEquals(t, responseWriter.Code, http.StatusOK) test.AssertEquals(t, prob.HTTPStatus, http.StatusMethodNotAllowed) test.AssertEquals(t, prob.Detail, "Method not allowed") } else { // Otherwise if it was an allowed method, ensure that the response was // *not* StatusMethodNotAllowed test.AssertNotEquals(t, responseWriter.Code, http.StatusMethodNotAllowed) } } }) } } func TestGetChallenge(t *testing.T) { wfe, _, _ := setupWFE(t) challengeURL := "http://localhost/acme/chall-v3/1/-ZfxEw" for _, method := range []string{"GET", "HEAD"} { resp := httptest.NewRecorder() req, err := http.NewRequest(method, challengeURL, nil) req.URL.Path = "1/-ZfxEw" test.AssertNotError(t, err, "Could not make NewRequest") wfe.Challenge(ctx, newRequestEvent(), resp, req) test.AssertEquals(t, resp.Code, http.StatusOK) test.AssertEquals(t, resp.Header().Get("Location"), challengeURL) test.AssertEquals(t, resp.Header().Get("Content-Type"), "application/json") test.AssertEquals(t, resp.Header().Get("Link"), `;rel="up"`) // Body is only relevant for GET. For HEAD, body will // be discarded by HandleFunc() anyway, so it doesn't // matter what Challenge() writes to it. if method == "GET" { test.AssertUnmarshaledEquals( t, resp.Body.String(), `{"status": "pending", "type":"dns","token":"token","url":"http://localhost/acme/chall-v3/1/-ZfxEw"}`) } } } func TestChallenge(t *testing.T) { wfe, _, signer := setupWFE(t) post := func(path string) *http.Request { signedURL := fmt.Sprintf("http://localhost/%s", path) _, _, jwsBody := signer.byKeyID(1, nil, signedURL, `{}`) return makePostRequestWithPath(path, jwsBody) } postAsGet := func(keyID int64, path, body string) *http.Request { _, _, jwsBody := signer.byKeyID(keyID, nil, fmt.Sprintf("http://localhost/%s", path), body) return makePostRequestWithPath(path, jwsBody) } testCases := []struct { Name string Request *http.Request ExpectedStatus int ExpectedHeaders map[string]string ExpectedBody string }{ { Name: "Valid challenge", Request: post("1/-ZfxEw"), ExpectedStatus: http.StatusOK, ExpectedHeaders: map[string]string{ "Location": "http://localhost/acme/chall-v3/1/-ZfxEw", "Link": `;rel="up"`, }, ExpectedBody: `{"status": "pending", "type":"dns","token":"token","url":"http://localhost/acme/chall-v3/1/-ZfxEw"}`, }, { Name: "Expired challenge", Request: post("3/-ZfxEw"), ExpectedStatus: http.StatusNotFound, ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Expired authorization","status":404}`, }, { Name: "Missing challenge", Request: post("1/"), ExpectedStatus: http.StatusNotFound, ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No such challenge","status":404}`, }, { Name: "Unspecified database error", Request: post("4/-ZfxEw"), ExpectedStatus: http.StatusInternalServerError, ExpectedBody: `{"type":"` + probs.ErrorNS + `serverInternal","detail":"Problem getting authorization","status":500}`, }, { Name: "POST-as-GET, wrong owner", Request: postAsGet(1, "5/-ZfxEw", ""), ExpectedStatus: http.StatusForbidden, ExpectedBody: `{"type":"` + probs.ErrorNS + `unauthorized","detail":"User account ID doesn't match account ID in authorization","status":403}`, }, { Name: "Valid POST-as-GET", Request: postAsGet(1, "1/-ZfxEw", ""), ExpectedStatus: http.StatusOK, ExpectedBody: `{"status": "pending", "type":"dns", "token":"token", "url": "http://localhost/acme/chall-v3/1/-ZfxEw"}`, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { responseWriter := httptest.NewRecorder() wfe.Challenge(ctx, newRequestEvent(), responseWriter, tc.Request) // Check the response code, headers and body match expected headers := responseWriter.Header() body := responseWriter.Body.String() test.AssertEquals(t, responseWriter.Code, tc.ExpectedStatus) for h, v := range tc.ExpectedHeaders { test.AssertEquals(t, headers.Get(h), v) } test.AssertUnmarshaledEquals(t, body, tc.ExpectedBody) }) } } // MockRAPerformValidationError is a mock RA that just returns an error on // PerformValidation. type MockRAPerformValidationError struct { MockRegistrationAuthority } func (ra *MockRAPerformValidationError) PerformValidation(context.Context, *rapb.PerformValidationRequest, ...grpc.CallOption) (*corepb.Authorization, error) { return nil, errors.New("broken on purpose") } // TestUpdateChallengeFinalizedAuthz tests that POSTing a challenge associated // with an already valid authorization just returns the challenge without calling // the RA. func TestUpdateChallengeFinalizedAuthz(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.ra = &MockRAPerformValidationError{} responseWriter := httptest.NewRecorder() signedURL := "http://localhost/1/-ZfxEw" _, _, jwsBody := signer.byKeyID(1, nil, signedURL, `{}`) request := makePostRequestWithPath("1/-ZfxEw", jwsBody) wfe.Challenge(ctx, newRequestEvent(), responseWriter, request) body := responseWriter.Body.String() test.AssertUnmarshaledEquals(t, body, `{ "status": "pending", "type": "dns", "token":"token", "url": "http://localhost/acme/chall-v3/1/-ZfxEw" }`) } // TestUpdateChallengeRAError tests that when the RA returns an error from // PerformValidation that the WFE returns an internal server error as expected // and does not panic or otherwise bug out. func TestUpdateChallengeRAError(t *testing.T) { wfe, _, signer := setupWFE(t) // Mock the RA to always fail PerformValidation wfe.ra = &MockRAPerformValidationError{} // Update a pending challenge signedURL := "http://localhost/2/-ZfxEw" _, _, jwsBody := signer.byKeyID(1, nil, signedURL, `{}`) responseWriter := httptest.NewRecorder() request := makePostRequestWithPath("2/-ZfxEw", jwsBody) wfe.Challenge(ctx, newRequestEvent(), responseWriter, request) // The result should be an internal server error problem. body := responseWriter.Body.String() test.AssertUnmarshaledEquals(t, body, `{ "type": "urn:ietf:params:acme:error:serverInternal", "detail": "Unable to update challenge", "status": 500 }`) } func TestBadNonce(t *testing.T) { wfe, _, _ := setupWFE(t) key := loadKey(t, []byte(test2KeyPrivatePEM)) rsaKey, ok := key.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load RSA key") // NOTE: We deliberately do not set the NonceSource in the jose.SignerOptions // for this test in order to provoke a bad nonce error noNonceSigner, err := jose.NewSigner(jose.SigningKey{ Key: rsaKey, Algorithm: jose.RS256, }, &jose.SignerOptions{ EmbedJWK: true, }) test.AssertNotError(t, err, "Failed to make signer") responseWriter := httptest.NewRecorder() result, err := noNonceSigner.Sign([]byte(`{"contact":["mailto:person@mail.com"]}`)) test.AssertNotError(t, err, "Failed to sign body") wfe.NewAccount(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("nonce", result.FullSerialize())) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type":"`+probs.ErrorNS+`badNonce","detail":"JWS has no anti-replay nonce","status":400}`) } func TestNewECDSAAccount(t *testing.T) { wfe, _, signer := setupWFE(t) // E1 always exists; E2 never exists key := loadKey(t, []byte(testE2KeyPrivatePEM)) _, ok := key.(*ecdsa.PrivateKey) test.Assert(t, ok, "Couldn't load ECDSA key") payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` path := newAcctPath signedURL := fmt.Sprintf("http://localhost%s", path) _, _, body := signer.embeddedJWK(key, signedURL, payload) request := makePostRequestWithPath(path, body) responseWriter := httptest.NewRecorder() wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) var acct core.Registration responseBody := responseWriter.Body.String() err := json.Unmarshal([]byte(responseBody), &acct) test.AssertNotError(t, err, "Couldn't unmarshal returned account object") test.Assert(t, len(*acct.Contact) >= 1, "No contact field in account") test.AssertEquals(t, (*acct.Contact)[0], "mailto:person@mail.com") test.AssertEquals(t, acct.Agreement, "") test.AssertEquals(t, acct.InitialIP.String(), "1.1.1.1") test.AssertEquals(t, responseWriter.Header().Get("Location"), "http://localhost/acme/acct/1") key = loadKey(t, []byte(testE1KeyPrivatePEM)) _, ok = key.(*ecdsa.PrivateKey) test.Assert(t, ok, "Couldn't load ECDSA key") _, _, body = signer.embeddedJWK(key, signedURL, payload) request = makePostRequestWithPath(path, body) // Reset the body and status code responseWriter = httptest.NewRecorder() // POST, Valid JSON, Key already in use wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ "key": { "kty": "EC", "crv": "P-256", "x": "FwvSZpu06i3frSk_mz9HcD9nETn4wf3mQ-zDtG21Gao", "y": "S8rR-0dWa8nAcw1fbunF_ajS3PQZ-QwLps-2adgLgPk" }, "initialIp": "", "status": "" }`) test.AssertEquals(t, responseWriter.Header().Get("Location"), "http://localhost/acme/acct/3") test.AssertEquals(t, responseWriter.Code, 200) // test3KeyPrivatePEM is a private key corresponding to a deactivated account in the mock SA's GetRegistration test data. key = loadKey(t, []byte(test3KeyPrivatePEM)) _, ok = key.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load test3 key") // Reset the body and status code responseWriter = httptest.NewRecorder() // Test POST valid JSON with deactivated account payload = `{}` path = "1" signedURL = "http://localhost/1" _, _, body = signer.embeddedJWK(key, signedURL, payload) request = makePostRequestWithPath(path, body) wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) } // Test that the WFE handling of the "empty update" POST is correct. The ACME // spec describes how when clients wish to query the server for information // about an account an empty account update should be sent, and // a populated acct object will be returned. func TestEmptyAccount(t *testing.T) { wfe, _, signer := setupWFE(t) responseWriter := httptest.NewRecorder() // Test Key 1 is mocked in the mock StorageAuthority used in setupWFE to // return a populated account for GetRegistrationByKey when test key 1 is // used. key := loadKey(t, []byte(test1KeyPrivatePEM)) _, ok := key.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load RSA key") payload := `{}` path := "1" signedURL := "http://localhost/1" _, _, body := signer.byKeyID(1, key, signedURL, payload) request := makePostRequestWithPath(path, body) // Send an account update with the trivial body wfe.Account( ctx, newRequestEvent(), responseWriter, request) responseBody := responseWriter.Body.String() // There should be no error test.AssertNotContains(t, responseBody, probs.ErrorNS) // We should get back a populated Account var acct core.Registration err := json.Unmarshal([]byte(responseBody), &acct) test.AssertNotError(t, err, "Couldn't unmarshal returned account object") test.Assert(t, len(*acct.Contact) >= 1, "No contact field in account") test.AssertEquals(t, (*acct.Contact)[0], "mailto:person@mail.com") test.AssertEquals(t, acct.Agreement, "") responseWriter.Body.Reset() } func TestNewAccount(t *testing.T) { wfe, _, signer := setupWFE(t) mux := wfe.Handler(metrics.NoopRegisterer) key := loadKey(t, []byte(test2KeyPrivatePEM)) _, ok := key.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load test2 key") path := newAcctPath signedURL := fmt.Sprintf("http://localhost%s", path) wrongAgreementAcct := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":false}` // An acct with the terms not agreed to _, _, wrongAgreementBody := signer.embeddedJWK(key, signedURL, wrongAgreementAcct) // A non-JSON payload _, _, fooBody := signer.embeddedJWK(key, signedURL, `foo`) type newAcctErrorTest struct { r *http.Request respBody string } acctErrTests := []newAcctErrorTest{ // POST, but no body. { &http.Request{ Method: "POST", URL: mustParseURL(newAcctPath), Header: map[string][]string{ "Content-Length": {"0"}, "Content-Type": {expectedJWSContentType}, }, }, `{"type":"` + probs.ErrorNS + `malformed","detail":"No body on POST","status":400}`, }, // POST, but body that isn't valid JWS { makePostRequestWithPath(newAcctPath, "hi"), `{"type":"` + probs.ErrorNS + `malformed","detail":"Parse error reading JWS","status":400}`, }, // POST, Properly JWS-signed, but payload is "foo", not base64-encoded JSON. { makePostRequestWithPath(newAcctPath, fooBody), `{"type":"` + probs.ErrorNS + `malformed","detail":"Request payload did not parse as JSON","status":400}`, }, // Same signed body, but payload modified by one byte, breaking signature. // should fail JWS verification. { makePostRequestWithPath(newAcctPath, `{"payload":"Zm9x","protected":"eyJhbGciOiJSUzI1NiIsImp3ayI6eyJrdHkiOiJSU0EiLCJuIjoicW5BUkxyVDdYejRnUmNLeUxkeWRtQ3ItZXk5T3VQSW1YNFg0MHRoazNvbjI2RmtNem5SM2ZSanM2NmVMSzdtbVBjQlo2dU9Kc2VVUlU2d0FhWk5tZW1vWXgxZE12cXZXV0l5aVFsZUhTRDdROHZCcmhSNnVJb080akF6SlpSLUNoelp1U0R0N2lITi0zeFVWc3B1NVhHd1hVX01WSlpzaFR3cDRUYUZ4NWVsSElUX09iblR2VE9VM1hoaXNoMDdBYmdaS21Xc1ZiWGg1cy1DcklpY1U0T2V4SlBndW5XWl9ZSkp1ZU9LbVR2bkxsVFY0TXpLUjJvWmxCS1oyN1MwLVNmZFZfUUR4X3lkbGU1b01BeUtWdGxBVjM1Y3lQTUlzWU53Z1VHQkNkWV8yVXppNWVYMGxUYzdNUFJ3ejZxUjFraXAtaTU5VmNHY1VRZ3FIVjZGeXF3IiwiZSI6IkFRQUIifSwia2lkIjoiIiwibm9uY2UiOiJyNHpuenZQQUVwMDlDN1JwZUtYVHhvNkx3SGwxZVBVdmpGeXhOSE1hQnVvIiwidXJsIjoiaHR0cDovL2xvY2FsaG9zdC9hY21lL25ldy1yZWcifQ","signature":"jcTdxSygm_cvD7KbXqsxgnoPApCTSkV4jolToSOd2ciRkg5W7Yl0ZKEEKwOc-dYIbQiwGiDzisyPCicwWsOUA1WSqHylKvZ3nxSMc6KtwJCW2DaOqcf0EEjy5VjiZJUrOt2c-r6b07tbn8sfOJKwlF2lsOeGi4s-rtvvkeQpAU-AWauzl9G4bv2nDUeCviAZjHx_PoUC-f9GmZhYrbDzAvXZ859ktM6RmMeD0OqPN7bhAeju2j9Gl0lnryZMtq2m0J2m1ucenQBL1g4ZkP1JiJvzd2cAz5G7Ftl2YeJJyWhqNd3qq0GVOt1P11s8PTGNaSoM0iR9QfUxT9A6jxARtg"}`), `{"type":"` + probs.ErrorNS + `malformed","detail":"JWS verification error","status":400}`, }, { makePostRequestWithPath(newAcctPath, wrongAgreementBody), `{"type":"` + probs.ErrorNS + `malformed","detail":"must agree to terms of service","status":400}`, }, } for _, rt := range acctErrTests { responseWriter := httptest.NewRecorder() mux.ServeHTTP(responseWriter, rt.r) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), rt.respBody) } responseWriter := httptest.NewRecorder() payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` _, _, body := signer.embeddedJWK(key, signedURL, payload) request := makePostRequestWithPath(path, body) wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) var acct core.Registration responseBody := responseWriter.Body.String() err := json.Unmarshal([]byte(responseBody), &acct) test.AssertNotError(t, err, "Couldn't unmarshal returned account object") test.Assert(t, len(*acct.Contact) >= 1, "No contact field in account") test.AssertEquals(t, (*acct.Contact)[0], "mailto:person@mail.com") test.AssertEquals(t, acct.InitialIP.String(), "1.1.1.1") // Agreement is an ACMEv1 field and should not be present test.AssertEquals(t, acct.Agreement, "") test.AssertEquals( t, responseWriter.Header().Get("Location"), "http://localhost/acme/acct/1") // Load an existing key key = loadKey(t, []byte(test1KeyPrivatePEM)) _, ok = key.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load test1 key") // Reset the body and status code responseWriter = httptest.NewRecorder() // POST, Valid JSON, Key already in use _, _, body = signer.embeddedJWK(key, signedURL, payload) request = makePostRequestWithPath(path, body) // POST the NewAccount request wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) // We expect a Location header and a 200 response with an empty body test.AssertEquals( t, responseWriter.Header().Get("Location"), "http://localhost/acme/acct/1") test.AssertEquals(t, responseWriter.Code, 200) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ "key": { "kty": "RSA", "n": "yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", "e": "AQAB" }, "contact": [ "mailto:person@mail.com" ], "initialIp": "", "status": "valid" }`) } func TestNewAccountWhenAccountHasBeenDeactivated(t *testing.T) { wfe, _, signer := setupWFE(t) signedURL := fmt.Sprintf("http://localhost%s", newAcctPath) // test3KeyPrivatePEM is a private key corresponding to a deactivated account in the mock SA's GetRegistration test data. k := loadKey(t, []byte(test3KeyPrivatePEM)) _, ok := k.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load test3 key") payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` _, _, body := signer.embeddedJWK(k, signedURL, payload) request := makePostRequestWithPath(newAcctPath, body) responseWriter := httptest.NewRecorder() wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) } func TestNewAccountNoID(t *testing.T) { wfe, _, signer := setupWFE(t) key := loadKey(t, []byte(test2KeyPrivatePEM)) _, ok := key.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load test2 key") path := newAcctPath signedURL := fmt.Sprintf("http://localhost%s", path) payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` _, _, body := signer.embeddedJWK(key, signedURL, payload) request := makePostRequestWithPath(path, body) responseWriter := httptest.NewRecorder() wfe.NewAccount(ctx, newRequestEvent(), responseWriter, request) responseBody := responseWriter.Body.String() test.AssertUnmarshaledEquals(t, responseBody, `{ "key": { "kty": "RSA", "n": "qnARLrT7Xz4gRcKyLdydmCr-ey9OuPImX4X40thk3on26FkMznR3fRjs66eLK7mmPcBZ6uOJseURU6wAaZNmemoYx1dMvqvWWIyiQleHSD7Q8vBrhR6uIoO4jAzJZR-ChzZuSDt7iHN-3xUVspu5XGwXU_MVJZshTwp4TaFx5elHIT_ObnTvTOU3Xhish07AbgZKmWsVbXh5s-CrIicU4OexJPgunWZ_YJJueOKmTvnLlTV4MzKR2oZlBKZ27S0-SfdV_QDx_ydle5oMAyKVtlAV35cyPMIsYNwgUGBCdY_2Uzi5eX0lTc7MPRwz6qR1kip-i59VcGcUQgqHV6Fyqw", "e": "AQAB" }, "contact": [ "mailto:person@mail.com" ], "initialIp": "1.1.1.1", "createdAt": "2021-01-01T00:00:00Z", "status": "" }`) } func TestGetAuthorization(t *testing.T) { wfe, _, signer := setupWFE(t) // Expired authorizations should be inaccessible authzURL := "3" responseWriter := httptest.NewRecorder() wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ Method: "GET", URL: mustParseURL(authzURL), }) test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type":"`+probs.ErrorNS+`malformed","detail":"Expired authorization","status":404}`) responseWriter.Body.Reset() // Ensure that a valid authorization can't be reached with an invalid URL wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ URL: mustParseURL("1d"), Method: "GET", }) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type":"`+probs.ErrorNS+`malformed","detail":"Invalid authorization ID","status":400}`) _, _, jwsBody := signer.byKeyID(1, nil, "http://localhost/1", "") postAsGet := makePostRequestWithPath("1", jwsBody) responseWriter = httptest.NewRecorder() // Ensure that a POST-as-GET to an authorization works wfe.Authorization(ctx, newRequestEvent(), responseWriter, postAsGet) test.AssertEquals(t, responseWriter.Code, http.StatusOK) body := responseWriter.Body.String() test.AssertUnmarshaledEquals(t, body, ` { "identifier": { "type": "dns", "value": "not-an-example.com" }, "status": "valid", "expires": "2070-01-01T00:00:00Z", "challenges": [ { "status": "pending", "type": "dns", "token":"token", "url": "http://localhost/acme/chall-v3/1/-ZfxEw" } ] }`) } // TestAuthorization500 tests that internal errors on GetAuthorization result in // a 500. func TestAuthorization500(t *testing.T) { wfe, _, _ := setupWFE(t) responseWriter := httptest.NewRecorder() wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ Method: "GET", URL: mustParseURL("4"), }) expected := `{ "type": "urn:ietf:params:acme:error:serverInternal", "detail": "Problem getting authorization", "status": 500 }` test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), expected) } // SAWithFailedChallenges is a mocks.StorageAuthority that has // a `GetAuthorization` implementation that can return authorizations with // failed challenges. type SAWithFailedChallenges struct { mocks.StorageAuthorityReadOnly Clk clock.FakeClock } func (sa *SAWithFailedChallenges) GetAuthorization2(ctx context.Context, id *sapb.AuthorizationID2, _ ...grpc.CallOption) (*corepb.Authorization, error) { authz := core.Authorization{ ID: "55", Status: core.StatusValid, RegistrationID: 1, Identifier: identifier.DNSIdentifier("not-an-example.com"), Challenges: []core.Challenge{ { Status: core.StatusInvalid, Type: "dns", Token: "exampleToken", Error: &probs.ProblemDetails{ Type: "things:are:whack", Detail: "whack attack", HTTPStatus: 555, }, }, }, } exp := sa.Clk.Now().AddDate(100, 0, 0) authz.Expires = &exp return bgrpc.AuthzToPB(authz) } // TestAuthorizationChallengeNamespace tests that the runtime prefixing of // Challenge Problem Types works as expected func TestAuthorizationChallengeNamespace(t *testing.T) { wfe, clk, _ := setupWFE(t) wfe.sa = &SAWithFailedChallenges{Clk: clk} responseWriter := httptest.NewRecorder() wfe.Authorization(ctx, newRequestEvent(), responseWriter, &http.Request{ Method: "GET", URL: mustParseURL("55"), }) var authz core.Authorization err := json.Unmarshal(responseWriter.Body.Bytes(), &authz) test.AssertNotError(t, err, "Couldn't unmarshal returned authorization object") test.AssertEquals(t, len(authz.Challenges), 1) // The Challenge Error Type should have had the probs.ErrorNS prefix added test.AssertEquals(t, string(authz.Challenges[0].Error.Type), probs.ErrorNS+"things:are:whack") responseWriter.Body.Reset() } func contains(s []string, e string) bool { for _, a := range s { if a == e { return true } } return false } func TestAccount(t *testing.T) { wfe, _, signer := setupWFE(t) mux := wfe.Handler(metrics.NoopRegisterer) responseWriter := httptest.NewRecorder() // Test GET proper entry returns 405 mux.ServeHTTP(responseWriter, &http.Request{ Method: "GET", URL: mustParseURL(acctPath), }) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type":"`+probs.ErrorNS+`malformed","detail":"Method not allowed","status":405}`) responseWriter.Body.Reset() // Test POST invalid JSON wfe.Account(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("2", "invalid")) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type":"`+probs.ErrorNS+`malformed","detail":"Parse error reading JWS","status":400}`) responseWriter.Body.Reset() key := loadKey(t, []byte(test2KeyPrivatePEM)) _, ok := key.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load RSA key") signedURL := fmt.Sprintf("http://localhost%s%d", acctPath, 102) path := fmt.Sprintf("%s%d", acctPath, 102) payload := `{}` // ID 102 is used by the mock for missing acct _, _, body := signer.byKeyID(102, nil, signedURL, payload) request := makePostRequestWithPath(path, body) // Test POST valid JSON but key is not registered wfe.Account(ctx, newRequestEvent(), responseWriter, request) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type":"`+probs.ErrorNS+`accountDoesNotExist","detail":"Account \"http://localhost/acme/acct/102\" not found","status":400}`) responseWriter.Body.Reset() key = loadKey(t, []byte(test1KeyPrivatePEM)) _, ok = key.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load RSA key") // Test POST valid JSON with account up in the mock payload = `{}` path = "1" signedURL = "http://localhost/1" _, _, body = signer.byKeyID(1, nil, signedURL, payload) request = makePostRequestWithPath(path, body) wfe.Account(ctx, newRequestEvent(), responseWriter, request) test.AssertNotContains(t, responseWriter.Body.String(), probs.ErrorNS) links := responseWriter.Header()["Link"] test.AssertEquals(t, contains(links, "<"+agreementURL+">;rel=\"terms-of-service\""), true) responseWriter.Body.Reset() // Test POST valid JSON with garbage in URL but valid account ID payload = `{}` signedURL = "http://localhost/a/bunch/of/garbage/1" _, _, body = signer.byKeyID(1, nil, signedURL, payload) request = makePostRequestWithPath("/a/bunch/of/garbage/1", body) wfe.Account(ctx, newRequestEvent(), responseWriter, request) test.AssertContains(t, responseWriter.Body.String(), "400") test.AssertContains(t, responseWriter.Body.String(), probs.ErrorNS+"malformed") responseWriter.Body.Reset() // Test valid POST-as-GET request responseWriter = httptest.NewRecorder() _, _, body = signer.byKeyID(1, nil, "http://localhost/1", "") request = makePostRequestWithPath("1", body) wfe.Account(ctx, newRequestEvent(), responseWriter, request) // It should not error test.AssertNotContains(t, responseWriter.Body.String(), probs.ErrorNS) test.AssertEquals(t, responseWriter.Code, http.StatusOK) altKey := loadKey(t, []byte(test2KeyPrivatePEM)) _, ok = altKey.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load altKey RSA key") // Test POST-as-GET request signed with wrong account key responseWriter = httptest.NewRecorder() _, _, body = signer.byKeyID(2, altKey, "http://localhost/1", "") request = makePostRequestWithPath("1", body) wfe.Account(ctx, newRequestEvent(), responseWriter, request) // It should error test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ "type": "urn:ietf:params:acme:error:unauthorized", "detail": "Request signing key did not match account key", "status": 403 }`) } type mockSAWithCert struct { sapb.StorageAuthorityReadOnlyClient cert *x509.Certificate status core.OCSPStatus } func newMockSAWithCert(t *testing.T, sa sapb.StorageAuthorityReadOnlyClient) *mockSAWithCert { cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") test.AssertNotError(t, err, "Failed to load test cert") return &mockSAWithCert{sa, cert, core.OCSPStatusGood} } // GetCertificate returns the mock SA's hard-coded certificate, issued by the // account with regID 1, if the given serial matches. Otherwise, returns not found. func (sa *mockSAWithCert) GetCertificate(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { if req.Serial != core.SerialToString(sa.cert.SerialNumber) { return nil, berrors.NotFoundError("Certificate with serial %q not found", req.Serial) } return &corepb.Certificate{ RegistrationID: 1, Serial: core.SerialToString(sa.cert.SerialNumber), Issued: sa.cert.NotBefore.UnixNano(), Expires: sa.cert.NotAfter.UnixNano(), Der: sa.cert.Raw, }, nil } // GetCertificateStatus returns the mock SA's status, if the given serial matches. // Otherwise, returns not found. func (sa *mockSAWithCert) GetCertificateStatus(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.CertificateStatus, error) { if req.Serial != core.SerialToString(sa.cert.SerialNumber) { return nil, berrors.NotFoundError("Status for certificate with serial %q not found", req.Serial) } return &corepb.CertificateStatus{ Serial: core.SerialToString(sa.cert.SerialNumber), Status: string(sa.status), }, nil } type mockSAWithIncident struct { sapb.StorageAuthorityReadOnlyClient incidents map[string]*sapb.Incidents } // newMockSAWithIncident returns a mock SA with an enabled (ongoing) incident // for each of the provided serials. func newMockSAWithIncident(sa sapb.StorageAuthorityReadOnlyClient, serial []string) *mockSAWithIncident { incidents := make(map[string]*sapb.Incidents) for _, s := range serial { incidents[s] = &sapb.Incidents{ Incidents: []*sapb.Incident{ { Id: 0, SerialTable: "incident_foo", Url: agreementURL, RenewBy: 0, Enabled: true, }, }, } } return &mockSAWithIncident{sa, incidents} } func (sa *mockSAWithIncident) IncidentsForSerial(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.Incidents, error) { incidents, ok := sa.incidents[req.Serial] if ok { return incidents, nil } return &sapb.Incidents{}, nil } func TestGetCertificate(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = newMockSAWithCert(t, wfe.sa) mux := wfe.Handler(metrics.NoopRegisterer) makeGet := func(path string) *http.Request { return &http.Request{URL: &url.URL{Path: path}, Method: "GET"} } makePost := func(keyID int64, key interface{}, path, body string) *http.Request { _, _, jwsBody := signer.byKeyID(keyID, key, fmt.Sprintf("http://localhost%s", path), body) return makePostRequestWithPath(path, jwsBody) } altKey := loadKey(t, []byte(test2KeyPrivatePEM)) _, ok := altKey.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load RSA key") certPemBytes, _ := os.ReadFile("../test/hierarchy/ee-r3.cert.pem") cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") test.AssertNotError(t, err, "failed to load test certificate") chainPemBytes, err := os.ReadFile("../test/hierarchy/int-r3.cert.pem") test.AssertNotError(t, err, "Error reading ../test/hierarchy/int-r3.cert.pem") chainCrossPemBytes, err := os.ReadFile("../test/hierarchy/int-r3-cross.cert.pem") test.AssertNotError(t, err, "Error reading ../test/hierarchy/int-r3-cross.cert.pem") reqPath := fmt.Sprintf("/acme/cert/%s", core.SerialToString(cert.SerialNumber)) pkixContent := "application/pem-certificate-chain" noCache := "public, max-age=0, no-cache" notFound := `{"type":"` + probs.ErrorNS + `malformed","detail":"Certificate not found","status":404}` testCases := []struct { Name string Request *http.Request ExpectedStatus int ExpectedHeaders map[string]string ExpectedLink string ExpectedBody string ExpectedCert []byte AnyCert bool }{ { Name: "Valid serial", Request: makeGet(reqPath), ExpectedStatus: http.StatusOK, ExpectedHeaders: map[string]string{ "Content-Type": pkixContent, }, ExpectedCert: append(certPemBytes, append([]byte("\n"), chainPemBytes...)...), ExpectedLink: fmt.Sprintf(`;rel="alternate"`, reqPath), }, { Name: "Valid serial, POST-as-GET", Request: makePost(1, nil, reqPath, ""), ExpectedStatus: http.StatusOK, ExpectedHeaders: map[string]string{ "Content-Type": pkixContent, }, ExpectedCert: append(certPemBytes, append([]byte("\n"), chainPemBytes...)...), }, { Name: "Valid serial, bad POST-as-GET", Request: makePost(1, nil, reqPath, "{}"), ExpectedStatus: http.StatusBadRequest, ExpectedBody: `{ "type": "urn:ietf:params:acme:error:malformed", "status": 400, "detail": "POST-as-GET requests must have an empty payload" }`, }, { Name: "Valid serial, POST-as-GET from wrong account", Request: makePost(2, altKey, reqPath, ""), ExpectedStatus: http.StatusForbidden, ExpectedBody: `{ "type": "urn:ietf:params:acme:error:unauthorized", "status": 403, "detail": "Account in use did not issue specified certificate" }`, }, { Name: "Unused serial, no cache", Request: makeGet("/acme/cert/000000000000000000000000000000000001"), ExpectedStatus: http.StatusNotFound, ExpectedBody: notFound, }, { Name: "Invalid serial, no cache", Request: makeGet("/acme/cert/nothex"), ExpectedStatus: http.StatusNotFound, ExpectedBody: notFound, }, { Name: "Another invalid serial, no cache", Request: makeGet("/acme/cert/00000000000000"), ExpectedStatus: http.StatusNotFound, ExpectedBody: notFound, }, { Name: "Valid serial (explicit default chain)", Request: makeGet(reqPath + "/0"), ExpectedStatus: http.StatusOK, ExpectedHeaders: map[string]string{ "Content-Type": pkixContent, }, ExpectedLink: fmt.Sprintf(`;rel="alternate"`, reqPath), ExpectedCert: append(certPemBytes, append([]byte("\n"), chainPemBytes...)...), }, { Name: "Valid serial (explicit alternate chain)", Request: makeGet(reqPath + "/1"), ExpectedStatus: http.StatusOK, ExpectedHeaders: map[string]string{ "Content-Type": pkixContent, }, ExpectedLink: fmt.Sprintf(`;rel="alternate"`, reqPath), ExpectedCert: append(certPemBytes, append([]byte("\n"), chainCrossPemBytes...)...), }, { Name: "Valid serial (explicit non-existent alternate chain)", Request: makeGet(reqPath + "/2"), ExpectedStatus: http.StatusNotFound, ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Unknown issuance chain","status":404}`, }, { Name: "Valid serial (explicit negative alternate chain)", Request: makeGet(reqPath + "/-1"), ExpectedStatus: http.StatusBadRequest, ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Chain ID must be a non-negative integer","status":400}`, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { responseWriter := httptest.NewRecorder() mockLog := wfe.log.(*blog.Mock) mockLog.Clear() // Mux a request for a certificate mux.ServeHTTP(responseWriter, tc.Request) headers := responseWriter.Header() // Assert that the status code written is as expected test.AssertEquals(t, responseWriter.Code, tc.ExpectedStatus) // All of the responses should have the correct cache control header test.AssertEquals(t, headers.Get("Cache-Control"), noCache) // If the test cases expects additional headers, check those too for h, v := range tc.ExpectedHeaders { test.AssertEquals(t, headers.Get(h), v) } if tc.ExpectedLink != "" { found := false links := headers["Link"] for _, link := range links { if link == tc.ExpectedLink { found = true break } } if !found { t.Errorf("Expected link '%s', but did not find it in (%v)", tc.ExpectedLink, links) } } if tc.AnyCert { // Certificate is randomly generated, don't match it return } if len(tc.ExpectedCert) > 0 { // If the expectation was to return a certificate, check that it was the one expected bodyBytes := responseWriter.Body.Bytes() test.Assert(t, bytes.Equal(bodyBytes, tc.ExpectedCert), "Certificates don't match") // Successful requests should be logged as such reqlogs := mockLog.GetAllMatching(`INFO: [^ ]+ [^ ]+ [^ ]+ 200 .*`) if len(reqlogs) != 1 { t.Errorf("Didn't find info logs with code 200. Instead got:\n%s\n", strings.Join(mockLog.GetAllMatching(`.*`), "\n")) } } else { // Otherwise if the expectation wasn't a certificate, check that the body matches the expected body := responseWriter.Body.String() test.AssertUnmarshaledEquals(t, body, tc.ExpectedBody) // Unsuccessful requests should be logged as such reqlogs := mockLog.GetAllMatching(fmt.Sprintf(`INFO: [^ ]+ [^ ]+ [^ ]+ %d .*`, tc.ExpectedStatus)) if len(reqlogs) != 1 { t.Errorf("Didn't find info logs with code %d. Instead got:\n%s\n", tc.ExpectedStatus, strings.Join(mockLog.GetAllMatching(`.*`), "\n")) } } }) } } type mockSAWithNewCert struct { sapb.StorageAuthorityReadOnlyClient clk clock.Clock } func (sa *mockSAWithNewCert) GetCertificate(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { issuer, err := core.LoadCert("../test/hierarchy/int-e1.cert.pem") if err != nil { return nil, fmt.Errorf("failed to load test issuer cert: %w", err) } issuerKeyPem, err := os.ReadFile("../test/hierarchy/int-e1.key.pem") if err != nil { return nil, fmt.Errorf("failed to load test issuer key: %w", err) } issuerKey := loadKey(&testing.T{}, issuerKeyPem) newKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { return nil, fmt.Errorf("failed to create test key: %w", err) } sn, err := core.StringToSerial(req.Serial) if err != nil { return nil, fmt.Errorf("failed to parse test serial: %w", err) } template := &x509.Certificate{ SerialNumber: sn, DNSNames: []string{"new.ee.boulder.test"}, } certDER, err := x509.CreateCertificate(rand.Reader, template, issuer, &newKey.PublicKey, issuerKey) if err != nil { return nil, fmt.Errorf("failed to issue test cert: %w", err) } cert, err := x509.ParseCertificate(certDER) if err != nil { return nil, fmt.Errorf("failed to parse test cert: %w", err) } return &corepb.Certificate{ RegistrationID: 1, Serial: core.SerialToString(cert.SerialNumber), Issued: sa.clk.Now().Add(-1 * time.Second).UnixNano(), Der: cert.Raw, }, nil } // TestGetCertificateNew tests for the case when the certificate is new (by // dynamically generating it at test time), and therefore isn't served by the // GET api. func TestGetCertificateNew(t *testing.T) { wfe, fc, signer := setupWFE(t) wfe.sa = &mockSAWithNewCert{wfe.sa, fc} mux := wfe.Handler(metrics.NoopRegisterer) makeGet := func(path string) *http.Request { return &http.Request{URL: &url.URL{Path: path}, Method: "GET"} } makePost := func(keyID int64, key interface{}, path, body string) *http.Request { _, _, jwsBody := signer.byKeyID(keyID, key, fmt.Sprintf("http://localhost%s", path), body) return makePostRequestWithPath(path, jwsBody) } altKey := loadKey(t, []byte(test2KeyPrivatePEM)) _, ok := altKey.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load RSA key") pkixContent := "application/pem-certificate-chain" noCache := "public, max-age=0, no-cache" testCases := []struct { Name string Request *http.Request ExpectedStatus int ExpectedHeaders map[string]string ExpectedBody string }{ { Name: "Get", Request: makeGet("/get/cert/000000000000000000000000000000000001"), ExpectedStatus: http.StatusForbidden, ExpectedBody: `{ "type": "` + probs.ErrorNS + `unauthorized", "detail": "Certificate is too new for GET API. You should only use this non-standard API to access resources created more than 10s ago", "status": 403 }`, }, { Name: "ACME Get", Request: makeGet("/acme/cert/000000000000000000000000000000000002"), ExpectedStatus: http.StatusOK, ExpectedHeaders: map[string]string{ "Content-Type": pkixContent, }, }, { Name: "ACME POST-as-GET", Request: makePost(1, nil, "/acme/cert/000000000000000000000000000000000003", ""), ExpectedStatus: http.StatusOK, ExpectedHeaders: map[string]string{ "Content-Type": pkixContent, }, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { responseWriter := httptest.NewRecorder() mockLog := wfe.log.(*blog.Mock) mockLog.Clear() // Mux a request for a certificate mux.ServeHTTP(responseWriter, tc.Request) headers := responseWriter.Header() // Assert that the status code written is as expected test.AssertEquals(t, responseWriter.Code, tc.ExpectedStatus) // All of the responses should have the correct cache control header test.AssertEquals(t, headers.Get("Cache-Control"), noCache) // If the test cases expects additional headers, check those too for h, v := range tc.ExpectedHeaders { test.AssertEquals(t, headers.Get(h), v) } // If we're expecting a particular body (because of an error), check that. if tc.ExpectedBody != "" { body := responseWriter.Body.String() test.AssertUnmarshaledEquals(t, body, tc.ExpectedBody) // Unsuccessful requests should be logged as such reqlogs := mockLog.GetAllMatching(fmt.Sprintf(`INFO: [^ ]+ [^ ]+ [^ ]+ %d .*`, tc.ExpectedStatus)) if len(reqlogs) != 1 { t.Errorf("Didn't find info logs with code %d. Instead got:\n%s\n", tc.ExpectedStatus, strings.Join(mockLog.GetAllMatching(`.*`), "\n")) } } }) } } // This uses httptest.NewServer because ServeMux.ServeHTTP won't prevent the // body from being sent like the net/http Server's actually do. func TestGetCertificateHEADHasCorrectBodyLength(t *testing.T) { wfe, _, _ := setupWFE(t) wfe.sa = newMockSAWithCert(t, wfe.sa) certPemBytes, _ := os.ReadFile("../test/hierarchy/ee-r3.cert.pem") cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") test.AssertNotError(t, err, "failed to load test certificate") chainPemBytes, err := os.ReadFile("../test/hierarchy/int-r3.cert.pem") test.AssertNotError(t, err, "Error reading ../test/hierarchy/int-r3.cert.pem") chain := fmt.Sprintf("%s\n%s", string(certPemBytes), string(chainPemBytes)) chainLen := strconv.Itoa(len(chain)) mockLog := wfe.log.(*blog.Mock) mockLog.Clear() mux := wfe.Handler(metrics.NoopRegisterer) s := httptest.NewServer(mux) defer s.Close() req, _ := http.NewRequest( "HEAD", fmt.Sprintf("%s/acme/cert/%s", s.URL, core.SerialToString(cert.SerialNumber)), nil) resp, err := http.DefaultClient.Do(req) if err != nil { test.AssertNotError(t, err, "do error") } body, err := io.ReadAll(resp.Body) if err != nil { test.AssertNotEquals(t, err, "readall error") } err = resp.Body.Close() if err != nil { test.AssertNotEquals(t, err, "readall error") } test.AssertEquals(t, resp.StatusCode, 200) test.AssertEquals(t, chainLen, resp.Header.Get("Content-Length")) test.AssertEquals(t, 0, len(body)) } type mockSAWithError struct { sapb.StorageAuthorityReadOnlyClient } func (sa *mockSAWithError) GetCertificate(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*corepb.Certificate, error) { return nil, errors.New("Oops") } func TestGetCertificateServerError(t *testing.T) { // TODO: add tests for failure to parse the retrieved cert, a cert whose // IssuerNameID is unknown, and a cert whose signature can't be verified. wfe, _, _ := setupWFE(t) wfe.sa = &mockSAWithError{wfe.sa} mux := wfe.Handler(metrics.NoopRegisterer) cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") test.AssertNotError(t, err, "failed to load test certificate") reqPath := fmt.Sprintf("/acme/cert/%s", core.SerialToString(cert.SerialNumber)) req := &http.Request{URL: &url.URL{Path: reqPath}, Method: "GET"} // Mux a request for a certificate responseWriter := httptest.NewRecorder() mux.ServeHTTP(responseWriter, req) test.AssertEquals(t, responseWriter.Code, http.StatusInternalServerError) noCache := "public, max-age=0, no-cache" test.AssertEquals(t, responseWriter.Header().Get("Cache-Control"), noCache) body := `{ "type": "urn:ietf:params:acme:error:serverInternal", "status": 500, "detail": "Failed to retrieve certificate" }` test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), body) } func newRequestEvent() *web.RequestEvent { return &web.RequestEvent{Extra: make(map[string]interface{})} } func TestHeaderBoulderRequester(t *testing.T) { wfe, _, signer := setupWFE(t) mux := wfe.Handler(metrics.NoopRegisterer) responseWriter := httptest.NewRecorder() key := loadKey(t, []byte(test1KeyPrivatePEM)) _, ok := key.(*rsa.PrivateKey) test.Assert(t, ok, "Failed to load test 1 RSA key") payload := `{}` path := fmt.Sprintf("%s%d", acctPath, 1) signedURL := fmt.Sprintf("http://localhost%s", path) _, _, body := signer.byKeyID(1, nil, signedURL, payload) request := makePostRequestWithPath(path, body) mux.ServeHTTP(responseWriter, request) test.AssertEquals(t, responseWriter.Header().Get("Boulder-Requester"), "1") // requests that do call sendError() also should have the requester header payload = `{"agreement":"https://letsencrypt.org/im-bad"}` _, _, body = signer.byKeyID(1, nil, signedURL, payload) request = makePostRequestWithPath(path, body) mux.ServeHTTP(responseWriter, request) test.AssertEquals(t, responseWriter.Header().Get("Boulder-Requester"), "1") } func TestDeactivateAuthorization(t *testing.T) { wfe, _, signer := setupWFE(t) responseWriter := httptest.NewRecorder() responseWriter.Body.Reset() payload := `{"status":""}` _, _, body := signer.byKeyID(1, nil, "http://localhost/1", payload) request := makePostRequestWithPath("1", body) wfe.Authorization(ctx, newRequestEvent(), responseWriter, request) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type": "`+probs.ErrorNS+`malformed","detail": "Invalid status value","status": 400}`) responseWriter.Body.Reset() payload = `{"status":"deactivated"}` _, _, body = signer.byKeyID(1, nil, "http://localhost/1", payload) request = makePostRequestWithPath("1", body) wfe.Authorization(ctx, newRequestEvent(), responseWriter, request) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ "identifier": { "type": "dns", "value": "not-an-example.com" }, "status": "deactivated", "expires": "2070-01-01T00:00:00Z", "challenges": [ { "status": "pending", "type": "dns", "token":"token", "url": "http://localhost/acme/chall-v3/1/-ZfxEw" } ] }`) } func TestDeactivateAccount(t *testing.T) { responseWriter := httptest.NewRecorder() wfe, _, signer := setupWFE(t) responseWriter.Body.Reset() payload := `{"status":"asd"}` signedURL := "http://localhost/1" path := "1" _, _, body := signer.byKeyID(1, nil, signedURL, payload) request := makePostRequestWithPath(path, body) wfe.Account(ctx, newRequestEvent(), responseWriter, request) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type": "`+probs.ErrorNS+`malformed","detail": "Invalid value provided for status field","status": 400}`) responseWriter.Body.Reset() payload = `{"status":"deactivated"}` _, _, body = signer.byKeyID(1, nil, signedURL, payload) request = makePostRequestWithPath(path, body) wfe.Account(ctx, newRequestEvent(), responseWriter, request) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ "key": { "kty": "RSA", "n": "yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", "e": "AQAB" }, "contact": [ "mailto:person@mail.com" ], "initialIp": "", "status": "deactivated" }`) responseWriter.Body.Reset() payload = `{"status":"deactivated", "contact":[]}` _, _, body = signer.byKeyID(1, nil, signedURL, payload) request = makePostRequestWithPath(path, body) wfe.Account(ctx, newRequestEvent(), responseWriter, request) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ "key": { "kty": "RSA", "n": "yNWVhtYEKJR21y9xsHV-PD_bYwbXSeNuFal46xYxVfRL5mqha7vttvjB_vc7Xg2RvgCxHPCqoxgMPTzHrZT75LjCwIW2K_klBYN8oYvTwwmeSkAz6ut7ZxPv-nZaT5TJhGk0NT2kh_zSpdriEJ_3vW-mqxYbbBmpvHqsa1_zx9fSuHYctAZJWzxzUZXykbWMWQZpEiE0J4ajj51fInEzVn7VxV-mzfMyboQjujPh7aNJxAWSq4oQEJJDgWwSh9leyoJoPpONHxh5nEE5AjE01FkGICSxjpZsF-w8hOTI3XXohUdu29Se26k2B0PolDSuj0GIQU6-W9TdLXSjBb2SpQ", "e": "AQAB" }, "contact": [ "mailto:person@mail.com" ], "initialIp": "", "status": "deactivated" }`) responseWriter.Body.Reset() key := loadKey(t, []byte(test3KeyPrivatePEM)) _, ok := key.(*rsa.PrivateKey) test.Assert(t, ok, "Couldn't load test3 RSA key") payload = `{"status":"deactivated"}` path = "3" signedURL = "http://localhost/3" _, _, body = signer.byKeyID(3, key, signedURL, payload) request = makePostRequestWithPath(path, body) wfe.Account(ctx, newRequestEvent(), responseWriter, request) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ "type": "`+probs.ErrorNS+`unauthorized", "detail": "Account is not valid, has status \"deactivated\"", "status": 403 }`) } func TestNewOrder(t *testing.T) { wfe, _, signer := setupWFE(t) responseWriter := httptest.NewRecorder() targetHost := "localhost" targetPath := "new-order" signedURL := fmt.Sprintf("http://%s/%s", targetHost, targetPath) nonDNSIdentifierBody := ` { "Identifiers": [ {"type": "dns", "value": "not-example.com"}, {"type": "dns", "value": "www.not-example.com"}, {"type": "fakeID", "value": "www.i-am-21.com"} ] } ` validOrderBody := ` { "Identifiers": [ {"type": "dns", "value": "not-example.com"}, {"type": "dns", "value": "www.not-example.com"} ] }` // Body with a SAN that is longer than 64 bytes. This one is 65 bytes. tooLongCNBody := ` { "Identifiers": [ { "type": "dns", "value": "thisreallylongexampledomainisabytelongerthanthemaxcnbytelimit.com" } ] }` oneLongOneShortCNBody := ` { "Identifiers": [ { "type": "dns", "value": "thisreallylongexampledomainisabytelongerthanthemaxcnbytelimit.com" }, { "type": "dns", "value": "not-example.com" } ] }` testCases := []struct { Name string Request *http.Request ExpectedBody string ExpectedHeaders map[string]string }{ { Name: "POST, but no body", Request: &http.Request{ Method: "POST", Header: map[string][]string{ "Content-Length": {"0"}, "Content-Type": {expectedJWSContentType}, }, }, ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No body on POST","status":400}`, }, { Name: "POST, with an invalid JWS body", Request: makePostRequestWithPath("hi", "hi"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Parse error reading JWS","status":400}`, }, { Name: "POST, properly signed JWS, payload isn't valid", Request: signAndPost(signer, targetPath, signedURL, "foo"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Request payload did not parse as JSON","status":400}`, }, { Name: "POST, empty domain name identifier", Request: signAndPost(signer, targetPath, signedURL, `{"identifiers":[{"type":"dns","value":""}]}`), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"NewOrder request included empty domain name","status":400}`, }, { Name: "POST, no identifiers in payload", Request: signAndPost(signer, targetPath, signedURL, "{}"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"NewOrder request did not specify any identifiers","status":400}`, }, { Name: "POST, non-DNS identifier in payload", Request: signAndPost(signer, targetPath, signedURL, nonDNSIdentifierBody), ExpectedBody: `{"type":"` + probs.ErrorNS + `unsupportedIdentifier","detail":"NewOrder request included invalid non-DNS type identifier: type \"fakeID\", value \"www.i-am-21.com\"","status":400}`, }, { Name: "POST, notAfter and notBefore in payload", Request: signAndPost(signer, targetPath, signedURL, `{"identifiers":[{"type": "dns", "value": "not-example.com"}], "notBefore":"now", "notAfter": "later"}`), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"NotBefore and NotAfter are not supported","status":400}`, }, { Name: "POST, no potential CNs 64 bytes or smaller", Request: signAndPost(signer, targetPath, signedURL, tooLongCNBody), ExpectedBody: `{"type":"` + probs.ErrorNS + `rejectedIdentifier","detail":"NewOrder request did not include a SAN short enough to fit in CN","status":400}`, }, { Name: "POST, good payload, one potential CNs less than 64 bytes and one longer", Request: signAndPost(signer, targetPath, signedURL, oneLongOneShortCNBody), ExpectedBody: ` { "status": "pending", "expires": "2021-02-01T01:01:01Z", "identifiers": [ { "type": "dns", "value": "thisreallylongexampledomainisabytelongerthanthemaxcnbytelimit.com"}, { "type": "dns", "value": "not-example.com"} ], "authorizations": [ "http://localhost/acme/authz-v3/1" ], "finalize": "http://localhost/acme/finalize/1/1" }`, }, { Name: "POST, good payload", Request: signAndPost(signer, targetPath, signedURL, validOrderBody), ExpectedBody: ` { "status": "pending", "expires": "2021-02-01T01:01:01Z", "identifiers": [ { "type": "dns", "value": "not-example.com"}, { "type": "dns", "value": "www.not-example.com"} ], "authorizations": [ "http://localhost/acme/authz-v3/1" ], "finalize": "http://localhost/acme/finalize/1/1" }`, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { responseWriter.Body.Reset() wfe.NewOrder(ctx, newRequestEvent(), responseWriter, tc.Request) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.ExpectedBody) headers := responseWriter.Header() for k, v := range tc.ExpectedHeaders { test.AssertEquals(t, headers.Get(k), v) } }) } // Test that we log the "Created" field. responseWriter.Body.Reset() request := signAndPost(signer, targetPath, signedURL, validOrderBody) requestEvent := newRequestEvent() wfe.NewOrder(ctx, requestEvent, responseWriter, request) if requestEvent.Created != "1" { t.Errorf("Expected to log Created field when creating Order: %#v", requestEvent) } } func TestFinalizeOrder(t *testing.T) { wfe, _, signer := setupWFE(t) responseWriter := httptest.NewRecorder() targetHost := "localhost" targetPath := "1/1" signedURL := fmt.Sprintf("http://%s/%s", targetHost, targetPath) // openssl req -outform der -new -nodes -key wfe/test/178.key -subj /CN=not-an-example.com | b64url // a valid CSR goodCertCSRPayload := `{ "csr": "MIICYjCCAUoCAQAwHTEbMBkGA1UEAwwSbm90LWFuLWV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmqs7nue5oFxKBk2WaFZJAma2nm1oFyPIq19gYEAdQN4mWvaJ8RjzHFkDMYUrlIrGxCYuFJDHFUk9dh19Na1MIY-NVLgcSbyNcOML3bLbLEwGmvXPbbEOflBA9mxUS9TLMgXW5ghf_qbt4vmSGKloIim41QXt55QFW6O-84s8Kd2OE6df0wTsEwLhZB3j5pDU-t7j5vTMv4Tc7EptaPkOdfQn-68viUJjlYM_4yIBVRhWCdexFdylCKVLg0obsghQEwULKYCUjdg6F0VJUI115DU49tzscXU_3FS3CyY8rchunuYszBNkdmgpAwViHNWuP7ESdEd_emrj1xuioSe6PwIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAE_T1nWU38XVYL28hNVSXU0rW5IBUKtbvr0qAkD4kda4HmQRTYkt-LNSuvxoZCC9lxijjgtJi-OJe_DCTdZZpYzewlVvcKToWSYHYQ6Wm1-fxxD_XzphvZOujpmBySchdiz7QSVWJmVZu34XD5RJbIcrmj_cjRt42J1hiTFjNMzQu9U6_HwIMmliDL-soFY2RTvvZf-dAFvOUQ-Wbxt97eM1PbbmxJNWRhbAmgEpe9PWDPTpqV5AK56VAa991cQ1P8ZVmPss5hvwGWhOtpnpTZVHN3toGNYFKqxWPboirqushQlfKiFqT9rpRgM3-mFjOHidGqsKEkTdmfSVlVEk3oo=" }` egUrl := mustParseURL("1/1") testCases := []struct { Name string Request *http.Request ExpectedHeaders map[string]string ExpectedBody string }{ { Name: "POST, but no body", Request: &http.Request{ URL: egUrl, RequestURI: targetPath, Method: "POST", Header: map[string][]string{ "Content-Length": {"0"}, "Content-Type": {expectedJWSContentType}, }, }, ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No body on POST","status":400}`, }, { Name: "POST, with an invalid JWS body", Request: makePostRequestWithPath(targetPath, "hi"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Parse error reading JWS","status":400}`, }, { Name: "POST, properly signed JWS, payload isn't valid", Request: signAndPost(signer, targetPath, signedURL, "foo"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Request payload did not parse as JSON","status":400}`, }, { Name: "Invalid path", Request: signAndPost(signer, "1", "http://localhost/1", "{}"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid request path","status":404}`, }, { Name: "Bad acct ID in path", Request: signAndPost(signer, "a/1", "http://localhost/a/1", "{}"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid account ID","status":400}`, }, { Name: "Mismatched acct ID in path/JWS", // Note(@cpu): We use "http://localhost/2/1" here not // "http://localhost/order/2/1" because we are calling the Order // handler directly and it normally has the initial path component // stripped by the global WFE2 handler. We need the JWS URL to match the request // URL so we fudge both such that the finalize-order prefix has been removed. Request: signAndPost(signer, "2/1", "http://localhost/2/1", "{}"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order found for account ID 2","status":404}`, }, { Name: "Order ID is invalid", Request: signAndPost(signer, "1/okwhatever/finalize-order", "http://localhost/1/okwhatever/finalize-order", "{}"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid order ID","status":400}`, }, { Name: "Order doesn't exist", // mocks/mocks.go's StorageAuthority's GetOrder mock treats ID 2 as missing Request: signAndPost(signer, "1/2", "http://localhost/1/2", "{}"), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order for ID 2","status":404}`, }, { Name: "Order is already finalized", // mocks/mocks.go's StorageAuthority's GetOrder mock treats ID 1 as an Order with a Serial Request: signAndPost(signer, "1/1", "http://localhost/1/1", goodCertCSRPayload), ExpectedBody: `{"type":"` + probs.ErrorNS + `orderNotReady","detail":"Order's status (\"valid\") is not acceptable for finalization","status":403}`, }, { Name: "Order is expired", // mocks/mocks.go's StorageAuthority's GetOrder mock treats ID 7 as an Order that has already expired Request: signAndPost(signer, "1/7", "http://localhost/1/7", goodCertCSRPayload), ExpectedBody: `{"type":"` + probs.ErrorNS + `malformed","detail":"Order 7 is expired","status":404}`, }, { Name: "Good CSR, Pending Order", Request: signAndPost(signer, "1/4", "http://localhost/1/4", goodCertCSRPayload), ExpectedBody: `{"type":"` + probs.ErrorNS + `orderNotReady","detail":"Order's status (\"pending\") is not acceptable for finalization","status":403}`, }, { Name: "Good CSR, Ready Order", Request: signAndPost(signer, "1/8", "http://localhost/1/8", goodCertCSRPayload), ExpectedHeaders: map[string]string{ "Location": "http://localhost/acme/order/1/8", "Retry-After": "3", }, ExpectedBody: ` { "status": "processing", "expires": "1970-01-01T00:00:00.9466848Z", "identifiers": [ {"type":"dns","value":"example.com"} ], "authorizations": [ "http://localhost/acme/authz-v3/1" ], "finalize": "http://localhost/acme/finalize/1/8" }`, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { responseWriter.Body.Reset() wfe.FinalizeOrder(ctx, newRequestEvent(), responseWriter, tc.Request) for k, v := range tc.ExpectedHeaders { got := responseWriter.Header().Get(k) if v != got { t.Errorf("Header %q: Expected %q, got %q", k, v, got) } } test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.ExpectedBody) }) } // Check a bad CSR request separately from the above testcases. We don't want // to match the whole response body because the "detail" of a bad CSR problem // contains a verbose Go error message that can change between versions (e.g. // Go 1.10.4 to 1.11 changed the expected format) badCSRReq := signAndPost(signer, "1/8", "http://localhost/1/8", `{"CSR": "ABCD"}`) responseWriter.Body.Reset() wfe.FinalizeOrder(ctx, newRequestEvent(), responseWriter, badCSRReq) responseBody := responseWriter.Body.String() test.AssertContains(t, responseBody, "Error parsing certificate request") } func TestKeyRollover(t *testing.T) { responseWriter := httptest.NewRecorder() wfe, _, signer := setupWFE(t) existingKey, err := rsa.GenerateKey(rand.Reader, 2048) test.AssertNotError(t, err, "Error creating random 2048 RSA key") newKeyBytes, err := os.ReadFile("../test/test-key-5.der") test.AssertNotError(t, err, "Failed to read ../test/test-key-5.der") newKeyPriv, err := x509.ParsePKCS1PrivateKey(newKeyBytes) test.AssertNotError(t, err, "Failed parsing private key") newJWKJSON, err := jose.JSONWebKey{Key: newKeyPriv.Public()}.MarshalJSON() test.AssertNotError(t, err, "Failed to marshal JWK JSON") wfe.KeyRollover(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("", "{}")) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{ "type": "`+probs.ErrorNS+`malformed", "detail": "Parse error reading JWS", "status": 400 }`) testCases := []struct { Name string Payload string ExpectedResponse string NewKey crypto.Signer ErrorStatType string }{ { Name: "Missing account URL", Payload: `{"oldKey":` + test1KeyPublicJSON + `}`, ExpectedResponse: `{ "type": "` + probs.ErrorNS + `malformed", "detail": "Inner key rollover request specified Account \"\", but outer JWS has Key ID \"http://localhost/acme/acct/1\"", "status": 400 }`, NewKey: newKeyPriv, ErrorStatType: "KeyRolloverMismatchedAccount", }, { Name: "incorrect old key", Payload: `{"oldKey":` + string(newJWKJSON) + `,"account":"http://localhost/acme/acct/1"}`, ExpectedResponse: `{ "type": "` + probs.ErrorNS + `malformed", "detail": "Inner JWS does not contain old key field matching current account key", "status": 400 }`, NewKey: newKeyPriv, ErrorStatType: "KeyRolloverWrongOldKey", }, { Name: "Valid key rollover request, key exists", Payload: `{"oldKey":` + test1KeyPublicJSON + `,"account":"http://localhost/acme/acct/1"}`, ExpectedResponse: `{ "type": "urn:ietf:params:acme:error:malformed", "detail": "New key is already in use for a different account", "status": 409 }`, NewKey: existingKey, }, { Name: "Valid key rollover request", Payload: `{"oldKey":` + test1KeyPublicJSON + `,"account":"http://localhost/acme/acct/1"}`, ExpectedResponse: `{ "key": ` + string(newJWKJSON) + `, "contact": [ "mailto:person@mail.com" ], "initialIp": "", "status": "valid" }`, NewKey: newKeyPriv, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { wfe.stats.joseErrorCount.Reset() responseWriter.Body.Reset() _, _, inner := signer.embeddedJWK(tc.NewKey, "http://localhost/key-change", tc.Payload) _, _, outer := signer.byKeyID(1, nil, "http://localhost/key-change", inner) wfe.KeyRollover(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("key-change", outer)) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.ExpectedResponse) if tc.ErrorStatType != "" { test.AssertMetricWithLabelsEquals( t, wfe.stats.joseErrorCount, prometheus.Labels{"type": tc.ErrorStatType}, 1) } }) } } func TestKeyRolloverMismatchedJWSURLs(t *testing.T) { responseWriter := httptest.NewRecorder() wfe, _, signer := setupWFE(t) newKeyBytes, err := os.ReadFile("../test/test-key-5.der") test.AssertNotError(t, err, "Failed to read ../test/test-key-5.der") newKeyPriv, err := x509.ParsePKCS1PrivateKey(newKeyBytes) test.AssertNotError(t, err, "Failed parsing private key") _, _, inner := signer.embeddedJWK(newKeyPriv, "http://localhost/wrong-url", "{}") _, _, outer := signer.byKeyID(1, nil, "http://localhost/key-change", inner) wfe.KeyRollover(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("key-change", outer)) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), ` { "type": "urn:ietf:params:acme:error:malformed", "detail": "Outer JWS 'url' value \"http://localhost/key-change\" does not match inner JWS 'url' value \"http://localhost/wrong-url\"", "status": 400 }`) } func TestGetOrder(t *testing.T) { wfe, _, signer := setupWFE(t) makeGet := func(path string) *http.Request { return &http.Request{URL: &url.URL{Path: path}, Method: "GET"} } makePost := func(keyID int64, path, body string) *http.Request { _, _, jwsBody := signer.byKeyID(keyID, nil, fmt.Sprintf("http://localhost/%s", path), body) return makePostRequestWithPath(path, jwsBody) } testCases := []struct { Name string Request *http.Request Response string Headers map[string]string Endpoint string }{ { Name: "Good request", Request: makeGet("1/1"), Response: `{"status": "valid","expires": "1970-01-01T00:00:00.9466848Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/1","certificate":"http://localhost/acme/cert/serial"}`, }, { Name: "404 request", Request: makeGet("1/2"), Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order for ID 2", "status":404}`, }, { Name: "Invalid request path", Request: makeGet("asd"), Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid request path","status":404}`, }, { Name: "Invalid account ID", Request: makeGet("asd/asd"), Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid account ID","status":400}`, }, { Name: "Invalid order ID", Request: makeGet("1/asd"), Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"Invalid order ID","status":400}`, }, { Name: "Real request, wrong account", Request: makeGet("2/1"), Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order found for account ID 2", "status":404}`, }, { Name: "Internal error request", Request: makeGet("1/3"), Response: `{"type":"` + probs.ErrorNS + `serverInternal","detail":"Failed to retrieve order for ID 3","status":500}`, }, { Name: "Invalid POST-as-GET", Request: makePost(1, "1/1", "{}"), Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"POST-as-GET requests must have an empty payload", "status":400}`, }, { Name: "Valid POST-as-GET, wrong account", Request: makePost(1, "2/1", ""), Response: `{"type":"` + probs.ErrorNS + `malformed","detail":"No order found for account ID 2", "status":404}`, }, { Name: "Valid POST-as-GET", Request: makePost(1, "1/1", ""), Response: `{"status": "valid","expires": "1970-01-01T00:00:00.9466848Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/1","certificate":"http://localhost/acme/cert/serial"}`, }, { Name: "GET new order", Request: makeGet("1/9"), Response: `{"type":"` + probs.ErrorNS + `unauthorized","detail":"Order is too new for GET API. You should only use this non-standard API to access resources created more than 10s ago","status":403}`, Endpoint: "/get/order/", }, { Name: "GET new order from old endpoint", Request: makeGet("1/9"), Response: `{"status": "valid","expires": "1970-01-01T00:00:00.9466848Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/9","certificate":"http://localhost/acme/cert/serial"}`, }, { Name: "POST-as-GET new order", Request: makePost(1, "1/9", ""), Response: `{"status": "valid","expires": "1970-01-01T00:00:00.9466848Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/9","certificate":"http://localhost/acme/cert/serial"}`, }, { Name: "POST-as-GET processing order", Request: makePost(1, "1/10", ""), Response: `{"status": "processing","expires": "1970-01-01T00:00:00.9466848Z","identifiers":[{"type":"dns", "value":"example.com"}], "authorizations":["http://localhost/acme/authz-v3/1"],"finalize":"http://localhost/acme/finalize/1/10"}`, Headers: map[string]string{"Retry-After": "3"}, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { responseWriter := httptest.NewRecorder() if tc.Endpoint != "" { wfe.GetOrder(ctx, &web.RequestEvent{Extra: make(map[string]interface{}), Endpoint: tc.Endpoint}, responseWriter, tc.Request) } else { wfe.GetOrder(ctx, newRequestEvent(), responseWriter, tc.Request) } test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.Response) for k, v := range tc.Headers { test.AssertEquals(t, responseWriter.Header().Get(k), v) } }) } } func makeRevokeRequestJSON(reason *revocation.Reason) ([]byte, error) { certPemBytes, err := os.ReadFile("../test/hierarchy/ee-r3.cert.pem") if err != nil { return nil, err } certBlock, _ := pem.Decode(certPemBytes) if err != nil { return nil, err } return makeRevokeRequestJSONForCert(certBlock.Bytes, reason) } func makeRevokeRequestJSONForCert(der []byte, reason *revocation.Reason) ([]byte, error) { revokeRequest := struct { CertificateDER core.JSONBuffer `json:"certificate"` Reason *revocation.Reason `json:"reason"` }{ CertificateDER: der, Reason: reason, } revokeRequestJSON, err := json.Marshal(revokeRequest) if err != nil { return nil, err } return revokeRequestJSON, nil } // Valid revocation request for existing, non-revoked cert, signed using the // issuing account key. func TestRevokeCertificateByApplicantValid(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = newMockSAWithCert(t, wfe.sa) mockLog := wfe.log.(*blog.Mock) mockLog.Clear() revokeRequestJSON, err := makeRevokeRequestJSON(nil) test.AssertNotError(t, err, "Failed to make revokeRequestJSON") _, _, jwsBody := signer.byKeyID(1, nil, "http://localhost/revoke-cert", string(revokeRequestJSON)) responseWriter := httptest.NewRecorder() wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("revoke-cert", jwsBody)) test.AssertEquals(t, responseWriter.Code, 200) test.AssertEquals(t, responseWriter.Body.String(), "") test.AssertDeepEquals(t, mockLog.GetAllMatching("Authenticated revocation"), []string{ `INFO: [AUDIT] Authenticated revocation JSON={"Serial":"000000000000000000001d72443db5189821","Reason":0,"RegID":1,"Method":"applicant"}`, }) } // Valid revocation request for existing, non-revoked cert, signed using the // certificate private key. func TestRevokeCertificateByKeyValid(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = newMockSAWithCert(t, wfe.sa) mockLog := wfe.log.(*blog.Mock) mockLog.Clear() keyPemBytes, err := os.ReadFile("../test/hierarchy/ee-r3.key.pem") test.AssertNotError(t, err, "Failed to load key") key := loadKey(t, keyPemBytes) revocationReason := revocation.Reason(ocsp.KeyCompromise) revokeRequestJSON, err := makeRevokeRequestJSON(&revocationReason) test.AssertNotError(t, err, "Failed to make revokeRequestJSON") _, _, jwsBody := signer.embeddedJWK(key, "http://localhost/revoke-cert", string(revokeRequestJSON)) responseWriter := httptest.NewRecorder() wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("revoke-cert", jwsBody)) test.AssertEquals(t, responseWriter.Code, 200) test.AssertEquals(t, responseWriter.Body.String(), "") test.AssertDeepEquals(t, mockLog.GetAllMatching("Authenticated revocation"), []string{ `INFO: [AUDIT] Authenticated revocation JSON={"Serial":"000000000000000000001d72443db5189821","Reason":1,"RegID":0,"Method":"privkey"}`, }) } // Invalid revocation request: although signed with the cert key, the cert // wasn't issued by any issuer the Boulder is aware of. func TestRevokeCertificateNotIssued(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = newMockSAWithCert(t, wfe.sa) // Make a self-signed junk certificate k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) test.AssertNotError(t, err, "unexpected error making random private key") // Use a known serial from the mockSAWithValidCert mock. // This ensures that any failures here are due to the certificate's issuer // not matching up with issuers known by the mock, rather than due to the // certificate's serial not matching up with serials known by the mock. knownCert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") test.AssertNotError(t, err, "Unexpected error loading test cert") template := &x509.Certificate{ SerialNumber: knownCert.SerialNumber, } certDER, err := x509.CreateCertificate(rand.Reader, template, template, k.Public(), k) test.AssertNotError(t, err, "Unexpected error creating self-signed junk cert") keyPemBytes, err := os.ReadFile("../test/hierarchy/ee-r3.key.pem") test.AssertNotError(t, err, "Failed to load key") key := loadKey(t, keyPemBytes) revokeRequestJSON, err := makeRevokeRequestJSONForCert(certDER, nil) test.AssertNotError(t, err, "Failed to make revokeRequestJSON for certDER") _, _, jwsBody := signer.embeddedJWK(key, "http://localhost/revoke-cert", string(revokeRequestJSON)) responseWriter := httptest.NewRecorder() wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("revoke-cert", jwsBody)) // It should result in a 404 response with a problem body test.AssertEquals(t, responseWriter.Code, 404) test.AssertEquals(t, responseWriter.Body.String(), "{\n \"type\": \"urn:ietf:params:acme:error:malformed\",\n \"detail\": \"Certificate from unrecognized issuer\",\n \"status\": 404\n}") } func TestRevokeCertificateExpired(t *testing.T) { wfe, fc, signer := setupWFE(t) wfe.sa = newMockSAWithCert(t, wfe.sa) keyPemBytes, err := os.ReadFile("../test/hierarchy/ee-r3.key.pem") test.AssertNotError(t, err, "Failed to load key") key := loadKey(t, keyPemBytes) revokeRequestJSON, err := makeRevokeRequestJSON(nil) test.AssertNotError(t, err, "Failed to make revokeRequestJSON") _, _, jwsBody := signer.embeddedJWK(key, "http://localhost/revoke-cert", string(revokeRequestJSON)) cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") test.AssertNotError(t, err, "Failed to load test certificate") fc.Set(cert.NotAfter.Add(time.Hour)) responseWriter := httptest.NewRecorder() wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("revoke-cert", jwsBody)) test.AssertEquals(t, responseWriter.Code, 403) test.AssertEquals(t, responseWriter.Body.String(), "{\n \"type\": \"urn:ietf:params:acme:error:unauthorized\",\n \"detail\": \"Certificate is expired\",\n \"status\": 403\n}") } func TestRevokeCertificateReasons(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = newMockSAWithCert(t, wfe.sa) ra := wfe.ra.(*MockRegistrationAuthority) reason0 := revocation.Reason(ocsp.Unspecified) reason1 := revocation.Reason(ocsp.KeyCompromise) reason2 := revocation.Reason(ocsp.CACompromise) reason100 := revocation.Reason(100) testCases := []struct { Name string Reason *revocation.Reason ExpectedHTTPCode int ExpectedBody string ExpectedReason *revocation.Reason }{ { Name: "Valid reason", Reason: &reason1, ExpectedHTTPCode: http.StatusOK, ExpectedReason: &reason1, }, { Name: "No reason", ExpectedHTTPCode: http.StatusOK, ExpectedReason: &reason0, }, { Name: "Unsupported reason", Reason: &reason2, ExpectedHTTPCode: http.StatusBadRequest, ExpectedBody: `{"type":"` + probs.ErrorNS + `badRevocationReason","detail":"unsupported revocation reason code provided: cACompromise (2). Supported reasons: unspecified (0), keyCompromise (1), superseded (4), cessationOfOperation (5)","status":400}`, }, { Name: "Non-existent reason", Reason: &reason100, ExpectedHTTPCode: http.StatusBadRequest, ExpectedBody: `{"type":"` + probs.ErrorNS + `badRevocationReason","detail":"unsupported revocation reason code provided: unknown (100). Supported reasons: unspecified (0), keyCompromise (1), superseded (4), cessationOfOperation (5)","status":400}`, }, } for _, tc := range testCases { t.Run(tc.Name, func(t *testing.T) { revokeRequestJSON, err := makeRevokeRequestJSON(tc.Reason) test.AssertNotError(t, err, "Failed to make revokeRequestJSON") _, _, jwsBody := signer.byKeyID(1, nil, "http://localhost/revoke-cert", string(revokeRequestJSON)) responseWriter := httptest.NewRecorder() wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("revoke-cert", jwsBody)) test.AssertEquals(t, responseWriter.Code, tc.ExpectedHTTPCode) if tc.ExpectedBody != "" { test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tc.ExpectedBody) } else { test.AssertEquals(t, responseWriter.Body.String(), tc.ExpectedBody) } if tc.ExpectedReason != nil { test.AssertEquals(t, ra.lastRevocationReason, *tc.ExpectedReason) } }) } } // A revocation request signed by an incorrect certificate private key. func TestRevokeCertificateWrongCertificateKey(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = newMockSAWithCert(t, wfe.sa) keyPemBytes, err := os.ReadFile("../test/hierarchy/ee-e1.key.pem") test.AssertNotError(t, err, "Failed to load key") key := loadKey(t, keyPemBytes) revocationReason := revocation.Reason(ocsp.KeyCompromise) revokeRequestJSON, err := makeRevokeRequestJSON(&revocationReason) test.AssertNotError(t, err, "Failed to make revokeRequestJSON") _, _, jwsBody := signer.embeddedJWK(key, "http://localhost/revoke-cert", string(revokeRequestJSON)) responseWriter := httptest.NewRecorder() wfe.RevokeCertificate(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("revoke-cert", jwsBody)) test.AssertEquals(t, responseWriter.Code, 403) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type":"`+probs.ErrorNS+`unauthorized","detail":"JWK embedded in revocation request must be the same public key as the cert to be revoked","status":403}`) } type mockSAGetRegByKeyFails struct { sapb.StorageAuthorityReadOnlyClient } func (sa *mockSAGetRegByKeyFails) GetRegistrationByKey(_ context.Context, req *sapb.JSONWebKey, _ ...grpc.CallOption) (*corepb.Registration, error) { return nil, fmt.Errorf("whoops") } // When SA.GetRegistrationByKey errors (e.g. gRPC timeout), NewAccount should // return internal server errors. func TestNewAccountWhenGetRegByKeyFails(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = &mockSAGetRegByKeyFails{wfe.sa} key := loadKey(t, []byte(testE2KeyPrivatePEM)) _, ok := key.(*ecdsa.PrivateKey) test.Assert(t, ok, "Couldn't load ECDSA key") payload := `{"contact":["mailto:person@mail.com"],"agreement":"` + agreementURL + `"}` responseWriter := httptest.NewRecorder() _, _, body := signer.embeddedJWK(key, "http://localhost/new-account", payload) wfe.NewAccount(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("/new-account", body)) if responseWriter.Code != 500 { t.Fatalf("Wrong response code %d for NewAccount with failing GetRegByKey (wanted 500)", responseWriter.Code) } var prob probs.ProblemDetails err := json.Unmarshal(responseWriter.Body.Bytes(), &prob) test.AssertNotError(t, err, "unmarshalling response") if prob.Type != probs.ErrorNS+probs.ServerInternalProblem { t.Errorf("Wrong type for returned problem: %#v", prob.Type) } } type mockSAGetRegByKeyNotFound struct { sapb.StorageAuthorityReadOnlyClient } func (sa *mockSAGetRegByKeyNotFound) GetRegistrationByKey(_ context.Context, req *sapb.JSONWebKey, _ ...grpc.CallOption) (*corepb.Registration, error) { return nil, berrors.NotFoundError("not found") } func TestNewAccountWhenGetRegByKeyNotFound(t *testing.T) { wfe, _, signer := setupWFE(t) wfe.sa = &mockSAGetRegByKeyNotFound{wfe.sa} key := loadKey(t, []byte(testE2KeyPrivatePEM)) _, ok := key.(*ecdsa.PrivateKey) test.Assert(t, ok, "Couldn't load ECDSA key") // When SA.GetRegistrationByKey returns NotFound, and no onlyReturnExisting // field is sent, NewAccount should succeed. payload := `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true}` signedURL := "http://localhost/new-account" responseWriter := httptest.NewRecorder() _, _, body := signer.embeddedJWK(key, signedURL, payload) wfe.NewAccount(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("/new-account", body)) if responseWriter.Code != http.StatusCreated { t.Errorf("Bad response to NewRegistration: %d, %s", responseWriter.Code, responseWriter.Body) } // When SA.GetRegistrationByKey returns NotFound, and onlyReturnExisting // field **is** sent, NewAccount should fail with the expected error. payload = `{"contact":["mailto:person@mail.com"],"termsOfServiceAgreed":true,"onlyReturnExisting":true}` responseWriter = httptest.NewRecorder() _, _, body = signer.embeddedJWK(key, signedURL, payload) // Process the new account request wfe.NewAccount(ctx, newRequestEvent(), responseWriter, makePostRequestWithPath("/new-account", body)) test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), ` { "type": "urn:ietf:params:acme:error:accountDoesNotExist", "detail": "No account exists with the provided key", "status": 400 }`) } func TestPrepAuthzForDisplay(t *testing.T) { wfe, _, _ := setupWFE(t) // Make an authz for a wildcard identifier authz := &core.Authorization{ ID: "12345", Status: core.StatusPending, RegistrationID: 1, Identifier: identifier.DNSIdentifier("*.example.com"), Challenges: []core.Challenge{ { Type: "dns", ProvidedKeyAuthorization: " 🔑", }, }, } // Prep the wildcard authz for display wfe.prepAuthorizationForDisplay(&http.Request{Host: "localhost"}, authz) // The authz should not have a wildcard prefix in the identifier value test.AssertEquals(t, strings.HasPrefix(authz.Identifier.Value, "*."), false) // The authz should be marked as corresponding to a wildcard name test.AssertEquals(t, authz.Wildcard, true) // We expect the authz challenge has its URL set and the URI emptied. authz.ID = "12345" wfe.prepAuthorizationForDisplay(&http.Request{Host: "localhost"}, authz) chal := authz.Challenges[0] test.AssertEquals(t, chal.URL, "http://localhost/acme/chall-v3/12345/po1V2w") test.AssertEquals(t, chal.URI, "") test.AssertEquals(t, chal.ProvidedKeyAuthorization, "") } // noSCTMockRA is a mock RA that always returns a `berrors.MissingSCTsError` from `FinalizeOrder` type noSCTMockRA struct { MockRegistrationAuthority } func (ra *noSCTMockRA) FinalizeOrder(context.Context, *rapb.FinalizeOrderRequest, ...grpc.CallOption) (*corepb.Order, error) { return nil, berrors.MissingSCTsError("noSCTMockRA missing scts error") } func TestFinalizeSCTError(t *testing.T) { wfe, _, signer := setupWFE(t) // Set up an RA mock that always returns a berrors.MissingSCTsError from // `FinalizeOrder` wfe.ra = &noSCTMockRA{} // Create a response writer to capture the WFE response responseWriter := httptest.NewRecorder() // Example CSR payload taken from `TestFinalizeOrder` // openssl req -outform der -new -nodes -key wfe/test/178.key -subj /CN=not-an-example.com | b64url // a valid CSR goodCertCSRPayload := `{ "csr": "MIICYjCCAUoCAQAwHTEbMBkGA1UEAwwSbm90LWFuLWV4YW1wbGUuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmqs7nue5oFxKBk2WaFZJAma2nm1oFyPIq19gYEAdQN4mWvaJ8RjzHFkDMYUrlIrGxCYuFJDHFUk9dh19Na1MIY-NVLgcSbyNcOML3bLbLEwGmvXPbbEOflBA9mxUS9TLMgXW5ghf_qbt4vmSGKloIim41QXt55QFW6O-84s8Kd2OE6df0wTsEwLhZB3j5pDU-t7j5vTMv4Tc7EptaPkOdfQn-68viUJjlYM_4yIBVRhWCdexFdylCKVLg0obsghQEwULKYCUjdg6F0VJUI115DU49tzscXU_3FS3CyY8rchunuYszBNkdmgpAwViHNWuP7ESdEd_emrj1xuioSe6PwIDAQABoAAwDQYJKoZIhvcNAQELBQADggEBAE_T1nWU38XVYL28hNVSXU0rW5IBUKtbvr0qAkD4kda4HmQRTYkt-LNSuvxoZCC9lxijjgtJi-OJe_DCTdZZpYzewlVvcKToWSYHYQ6Wm1-fxxD_XzphvZOujpmBySchdiz7QSVWJmVZu34XD5RJbIcrmj_cjRt42J1hiTFjNMzQu9U6_HwIMmliDL-soFY2RTvvZf-dAFvOUQ-Wbxt97eM1PbbmxJNWRhbAmgEpe9PWDPTpqV5AK56VAa991cQ1P8ZVmPss5hvwGWhOtpnpTZVHN3toGNYFKqxWPboirqushQlfKiFqT9rpRgM3-mFjOHidGqsKEkTdmfSVlVEk3oo=" }` // Create a finalization request with the above payload request := signAndPost(signer, "1/8", "http://localhost/1/8", goodCertCSRPayload) // POST the finalize order request. wfe.FinalizeOrder(ctx, newRequestEvent(), responseWriter, request) // We expect the berrors.MissingSCTsError error to have been converted into // a serverInternal error with the right message. test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), `{"type":"`+probs.ErrorNS+`serverInternal","detail":"Error finalizing order :: Unable to meet CA SCT embedding requirements","status":500}`) } func TestOrderToOrderJSONV2Authorizations(t *testing.T) { wfe, fc, _ := setupWFE(t) orderJSON := wfe.orderToOrderJSON(&http.Request{}, &corepb.Order{ Id: 1, RegistrationID: 1, Names: []string{"a"}, Status: string(core.StatusPending), Expires: fc.Now().UnixNano(), V2Authorizations: []int64{1, 2}, }) test.AssertDeepEquals(t, orderJSON.Authorizations, []string{ "http://localhost/acme/authz-v3/1", "http://localhost/acme/authz-v3/2", }) } func TestGetChallengeUpRel(t *testing.T) { wfe, _, _ := setupWFE(t) challengeURL := "http://localhost/acme/chall-v3/1/-ZfxEw" resp := httptest.NewRecorder() req, err := http.NewRequest("GET", challengeURL, nil) test.AssertNotError(t, err, "Could not make NewRequest") req.URL.Path = "1/-ZfxEw" wfe.Challenge(ctx, newRequestEvent(), resp, req) test.AssertEquals(t, resp.Code, http.StatusOK) test.AssertEquals(t, resp.Header().Get("Link"), `;rel="up"`) } func TestPrepAccountForDisplay(t *testing.T) { acct := &core.Registration{ ID: 1987, Agreement: "disagreement", } // Prep the account for display. prepAccountForDisplay(acct) // The Agreement should always be cleared. test.AssertEquals(t, acct.Agreement, "") // The ID field should be zeroed. test.AssertEquals(t, acct.ID, int64(0)) } func TestGETAPIAuthz(t *testing.T) { wfe, _, _ := setupWFE(t) makeGet := func(path, endpoint string) (*http.Request, *web.RequestEvent) { return &http.Request{URL: &url.URL{Path: path}, Method: "GET"}, &web.RequestEvent{Endpoint: endpoint} } testCases := []struct { name string path string expectTooFreshErr bool }{ { name: "fresh authz", path: "1", expectTooFreshErr: true, }, { name: "old authz", path: "2", expectTooFreshErr: false, }, } tooFreshErr := `{"type":"` + probs.ErrorNS + `unauthorized","detail":"Authorization is too new for GET API. You should only use this non-standard API to access resources created more than 10s ago","status":403}` for _, tc := range testCases { responseWriter := httptest.NewRecorder() req, logEvent := makeGet(tc.path, getAuthzPath) wfe.Authorization(context.Background(), logEvent, responseWriter, req) if responseWriter.Code == http.StatusOK && tc.expectTooFreshErr { t.Errorf("expected too fresh error, got http.StatusOK") } else { test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tooFreshErr) } } } func TestGETAPIChallenge(t *testing.T) { wfe, _, _ := setupWFE(t) makeGet := func(path, endpoint string) (*http.Request, *web.RequestEvent) { return &http.Request{URL: &url.URL{Path: path}, Method: "GET"}, &web.RequestEvent{Endpoint: endpoint} } testCases := []struct { name string path string expectTooFreshErr bool }{ { name: "fresh authz challenge", path: "1/-ZfxEw", expectTooFreshErr: true, }, { name: "old authz challenge", path: "2/-ZfxEw", expectTooFreshErr: false, }, } tooFreshErr := `{"type":"` + probs.ErrorNS + `unauthorized","detail":"Authorization is too new for GET API. You should only use this non-standard API to access resources created more than 10s ago","status":403}` for _, tc := range testCases { responseWriter := httptest.NewRecorder() req, logEvent := makeGet(tc.path, getAuthzPath) wfe.Challenge(context.Background(), logEvent, responseWriter, req) if responseWriter.Code == http.StatusOK && tc.expectTooFreshErr { t.Errorf("expected too fresh error, got http.StatusOK") } else { test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) test.AssertUnmarshaledEquals(t, responseWriter.Body.String(), tooFreshErr) } } } // TestGet404 tests that a 404 is served and that the expected endpoint of // "/" is logged when an unknown path is requested. This will test the // codepath to the wfe.Index() handler which handles "/" and all non-api // endpoint requests to make sure the endpoint is set properly in the logs. func TestIndexGet404(t *testing.T) { // Setup wfe, _, _ := setupWFE(t) path := "/nopathhere/nope/nofilehere" req := &http.Request{URL: &url.URL{Path: path}, Method: "GET"} logEvent := &web.RequestEvent{} responseWriter := httptest.NewRecorder() // Send a request to wfe.Index() wfe.Index(context.Background(), logEvent, responseWriter, req) // Test that a 404 is received as expected test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) // Test that we logged the "/" endpoint test.AssertEquals(t, logEvent.Endpoint, "/") // Test that the rest of the path is logged as the slug test.AssertEquals(t, logEvent.Slug, path[1:]) } // TestARI tests that requests for real certs result in renewal info, while // requests for certs that don't exist result in errors. func TestARI(t *testing.T) { wfe, _, _ := setupWFE(t) msa := newMockSAWithCert(t, wfe.sa) wfe.sa = msa err := features.Set(map[string]bool{"ServeRenewalInfo": true}) test.AssertNotError(t, err, "setting feature flag") defer features.Reset() makeGet := func(path, endpoint string) (*http.Request, *web.RequestEvent) { return &http.Request{URL: &url.URL{Path: path}, Method: "GET"}, &web.RequestEvent{Endpoint: endpoint, Extra: map[string]interface{}{}} } // Load the certificate and its issuer. cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") test.AssertNotError(t, err, "failed to load test certificate") issuer, err := core.LoadCert("../test/hierarchy/int-r3.cert.pem") test.AssertNotError(t, err, "failed to load test issuer") // Take advantage of OCSP to build the issuer hashes. ocspReqBytes, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{Hash: crypto.SHA256}) test.AssertNotError(t, err, "failed to create ocsp request") ocspReq, err := ocsp.ParseRequest(ocspReqBytes) test.AssertNotError(t, err, "failed to parse ocsp request") // Ensure that a correct query results in a 200. idBytes, err := asn1.Marshal(certID{ pkix.AlgorithmIdentifier{ // SHA256 Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}, Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, }, ocspReq.IssuerNameHash, ocspReq.IssuerKeyHash, cert.SerialNumber, }) test.AssertNotError(t, err, "failed to marshal certID") req, event := makeGet(base64.RawURLEncoding.EncodeToString(idBytes), renewalInfoPath) resp := httptest.NewRecorder() wfe.RenewalInfo(context.Background(), event, resp, req) test.AssertEquals(t, resp.Code, http.StatusOK) test.AssertEquals(t, resp.Header().Get("Retry-After"), "21600") var ri core.RenewalInfo err = json.Unmarshal(resp.Body.Bytes(), &ri) test.AssertNotError(t, err, "unmarshalling renewal info") test.Assert(t, ri.SuggestedWindow.Start.After(cert.NotBefore), "suggested window begins before cert issuance") test.Assert(t, ri.SuggestedWindow.End.Before(cert.NotAfter), "suggested window ends after cert expiry") // Ensure that a correct query for a revoked cert results in a renewal window // in the past. msa.status = core.OCSPStatusRevoked req, event = makeGet(base64.RawURLEncoding.EncodeToString(idBytes), renewalInfoPath) resp = httptest.NewRecorder() wfe.RenewalInfo(context.Background(), event, resp, req) test.AssertEquals(t, resp.Code, http.StatusOK) test.AssertEquals(t, resp.Header().Get("Retry-After"), "21600") err = json.Unmarshal(resp.Body.Bytes(), &ri) test.AssertNotError(t, err, "unmarshalling renewal info") test.Assert(t, ri.SuggestedWindow.End.Before(wfe.clk.Now()), "suggested window should end in the past") test.Assert(t, ri.SuggestedWindow.Start.Before(ri.SuggestedWindow.End), "suggested window should start before it ends") // Ensure that a query for a non-existent serial results in a 404. idBytes, err = asn1.Marshal(certID{ pkix.AlgorithmIdentifier{ // SHA256 Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}, Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, }, ocspReq.IssuerNameHash, ocspReq.IssuerKeyHash, big.NewInt(0).Add(cert.SerialNumber, big.NewInt(1)), }) test.AssertNotError(t, err, "failed to marshal certID") req, event = makeGet(base64.RawURLEncoding.EncodeToString(idBytes), renewalInfoPath) resp = httptest.NewRecorder() wfe.RenewalInfo(context.Background(), event, resp, req) test.AssertEquals(t, resp.Code, http.StatusNotFound) test.AssertEquals(t, resp.Header().Get("Retry-After"), "") // Ensure that a query with a bad hash algorithm fails. idBytes, err = asn1.Marshal(certID{ pkix.AlgorithmIdentifier{ // SHA-1 Algorithm: asn1.ObjectIdentifier{1, 3, 14, 3, 2, 26}, Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, }, ocspReq.IssuerNameHash, ocspReq.IssuerKeyHash, big.NewInt(0).Add(cert.SerialNumber, big.NewInt(1)), }) test.AssertNotError(t, err, "failed to marshal certID") req, event = makeGet(base64.RawURLEncoding.EncodeToString(idBytes), renewalInfoPath) resp = httptest.NewRecorder() wfe.RenewalInfo(context.Background(), event, resp, req) test.AssertEquals(t, resp.Code, http.StatusBadRequest) test.AssertContains(t, resp.Body.String(), "Request used hash algorithm other than SHA-256") // Ensure that a query with a non-CertID path fails. req, event = makeGet(base64.RawURLEncoding.EncodeToString(ocspReqBytes), renewalInfoPath) resp = httptest.NewRecorder() wfe.RenewalInfo(context.Background(), event, resp, req) test.AssertEquals(t, resp.Code, http.StatusBadRequest) test.AssertContains(t, resp.Body.String(), "Path was not a DER-encoded CertID sequence") // Ensure that a query with a non-Base64URL path (including one in the old // request path style, which included slashes) fails. req, event = makeGet( fmt.Sprintf( "%s/%s/%s", base64.RawURLEncoding.EncodeToString(ocspReq.IssuerNameHash), base64.RawURLEncoding.EncodeToString(ocspReq.IssuerKeyHash), base64.RawURLEncoding.EncodeToString(cert.SerialNumber.Bytes()), ), renewalInfoPath) resp = httptest.NewRecorder() wfe.RenewalInfo(context.Background(), event, resp, req) test.AssertEquals(t, resp.Code, http.StatusBadRequest) test.AssertContains(t, resp.Body.String(), "Path was not base64url-encoded") // Ensure that a query with no path slug at all bails out early. req, event = makeGet("", renewalInfoPath) resp = httptest.NewRecorder() wfe.RenewalInfo(context.Background(), event, resp, req) test.AssertEquals(t, resp.Code, http.StatusNotFound) test.AssertContains(t, resp.Body.String(), "Must specify a request path") } // TestIncidentARI tests that requests certs impacted by an ongoing revocation // incident result in a 200 with a retry-after header and a suggested retry // window in the past. func TestIncidentARI(t *testing.T) { wfe, _, _ := setupWFE(t) expectSerial := big.NewInt(12345) expectSerialString := core.SerialToString(big.NewInt(12345)) wfe.sa = newMockSAWithIncident(wfe.sa, []string{expectSerialString}) err := features.Set(map[string]bool{"ServeRenewalInfo": true}) test.AssertNotError(t, err, "setting feature flag") defer features.Reset() makeGet := func(path, endpoint string) (*http.Request, *web.RequestEvent) { return &http.Request{URL: &url.URL{Path: path}, Method: "GET"}, &web.RequestEvent{Endpoint: endpoint, Extra: map[string]interface{}{}} } idBytes, err := asn1.Marshal(certID{ pkix.AlgorithmIdentifier{ // SHA256 Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}, Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, }, []byte("foo"), []byte("baz"), expectSerial, }) test.AssertNotError(t, err, "failed to marshal certID") req, event := makeGet(base64.RawURLEncoding.EncodeToString(idBytes), renewalInfoPath) resp := httptest.NewRecorder() wfe.RenewalInfo(context.Background(), event, resp, req) test.AssertEquals(t, resp.Code, 200) test.AssertEquals(t, resp.Header().Get("Retry-After"), "21600") var ri core.RenewalInfo err = json.Unmarshal(resp.Body.Bytes(), &ri) test.AssertNotError(t, err, "unmarshalling renewal info") // The start of the window should be in the past. test.AssertEquals(t, ri.SuggestedWindow.Start.Before(wfe.clk.Now()), true) // The end of the window should be after the start. test.AssertEquals(t, ri.SuggestedWindow.End.After(ri.SuggestedWindow.Start), true) // The end of the window should also be in the past. test.AssertEquals(t, ri.SuggestedWindow.End.Before(wfe.clk.Now()), true) } type mockSAWithSerialMetadata struct { sapb.StorageAuthorityReadOnlyClient serial string regID int64 } // GetSerialMetadata returns fake metadata if it recognizes the given serial. func (sa *mockSAWithSerialMetadata) GetSerialMetadata(_ context.Context, req *sapb.Serial, _ ...grpc.CallOption) (*sapb.SerialMetadata, error) { if req.Serial != sa.serial { return nil, berrors.NotFoundError("metadata for certificate with serial %q not found", req.Serial) } return &sapb.SerialMetadata{ Serial: sa.serial, RegistrationID: sa.regID, }, nil } // TestUpdateARI tests that requests for real certs issued to the correct regID // are accepted, while all others result in errors. func TestUpdateARI(t *testing.T) { wfe, _, signer := setupWFE(t) err := features.Set(map[string]bool{"ServeRenewalInfo": true}) test.AssertNotError(t, err, "setting feature flag") defer features.Reset() makePost := func(regID int64, body string) *http.Request { signedURL := fmt.Sprintf("http://localhost%s", renewalInfoPath) _, _, jwsBody := signer.byKeyID(regID, nil, signedURL, body) return makePostRequestWithPath(renewalInfoPath, jwsBody) } type jsonReq struct { CertID string `json:"certID"` Replaced bool `json:"replaced"` } // Load a cert, its issuer, and use OCSP to compute issuer name/key hashes. cert, err := core.LoadCert("../test/hierarchy/ee-r3.cert.pem") test.AssertNotError(t, err, "failed to load test certificate") issuer, err := core.LoadCert("../test/hierarchy/int-r3.cert.pem") test.AssertNotError(t, err, "failed to load test issuer") ocspReqBytes, err := ocsp.CreateRequest(cert, issuer, &ocsp.RequestOptions{Hash: crypto.SHA256}) test.AssertNotError(t, err, "failed to create ocsp request") ocspReq, err := ocsp.ParseRequest(ocspReqBytes) test.AssertNotError(t, err, "failed to parse ocsp request") // Set up the mock SA. msa := mockSAWithSerialMetadata{wfe.sa, core.SerialToString(cert.SerialNumber), 1} wfe.sa = &msa // An empty POST should result in an error. req := makePost(1, "") responseWriter := httptest.NewRecorder() wfe.UpdateRenewal(ctx, newRequestEvent(), responseWriter, req) test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest) // Non-certID base64 should result in an error. req = makePost(1, "aGVsbG8gd29ybGQK") // $ echo "hello world" | base64 responseWriter = httptest.NewRecorder() wfe.UpdateRenewal(ctx, newRequestEvent(), responseWriter, req) test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest) // Non-sha256 hash algorithm should result in an error. idBytes, err := asn1.Marshal(certID{ pkix.AlgorithmIdentifier{ // definitely not SHA256 Algorithm: asn1.ObjectIdentifier{1, 2, 3, 4, 5}, Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, }, ocspReq.IssuerNameHash, ocspReq.IssuerKeyHash, cert.SerialNumber, }) test.AssertNotError(t, err, "failed to marshal certID") body, err := json.Marshal(jsonReq{ CertID: base64.RawURLEncoding.EncodeToString(idBytes), Replaced: true, }) test.AssertNotError(t, err, "failed to marshal request body") req = makePost(1, string(body)) responseWriter = httptest.NewRecorder() wfe.UpdateRenewal(ctx, newRequestEvent(), responseWriter, req) test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest) // Unrecognized serial should result in an error. idBytes, err = asn1.Marshal(certID{ pkix.AlgorithmIdentifier{ // SHA256 Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}, Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, }, ocspReq.IssuerNameHash, ocspReq.IssuerKeyHash, big.NewInt(12345), }) test.AssertNotError(t, err, "failed to marshal certID") body, err = json.Marshal(jsonReq{ CertID: base64.RawURLEncoding.EncodeToString(idBytes), Replaced: true, }) test.AssertNotError(t, err, "failed to marshal request body") req = makePost(1, string(body)) responseWriter = httptest.NewRecorder() wfe.UpdateRenewal(ctx, newRequestEvent(), responseWriter, req) test.AssertEquals(t, responseWriter.Code, http.StatusNotFound) // Recognized serial but owned by the wrong account should result in an error. msa.regID = 2 idBytes, err = asn1.Marshal(certID{ pkix.AlgorithmIdentifier{ // SHA256 Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}, Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, }, ocspReq.IssuerNameHash, ocspReq.IssuerKeyHash, cert.SerialNumber, }) test.AssertNotError(t, err, "failed to marshal certID") body, err = json.Marshal(jsonReq{ CertID: base64.RawURLEncoding.EncodeToString(idBytes), Replaced: true, }) test.AssertNotError(t, err, "failed to marshal request body") req = makePost(1, string(body)) responseWriter = httptest.NewRecorder() wfe.UpdateRenewal(ctx, newRequestEvent(), responseWriter, req) test.AssertEquals(t, responseWriter.Code, http.StatusForbidden) // Recognized serial and owned by the right account should work. msa.regID = 1 idBytes, err = asn1.Marshal(certID{ pkix.AlgorithmIdentifier{ // SHA256 Algorithm: asn1.ObjectIdentifier{2, 16, 840, 1, 101, 3, 4, 2, 1}, Parameters: asn1.RawValue{Tag: 5 /* ASN.1 NULL */}, }, ocspReq.IssuerNameHash, ocspReq.IssuerKeyHash, cert.SerialNumber, }) test.AssertNotError(t, err, "failed to marshal certID") body, err = json.Marshal(jsonReq{ CertID: base64.RawURLEncoding.EncodeToString(idBytes), Replaced: true, }) test.AssertNotError(t, err, "failed to marshal request body") req = makePost(1, string(body)) responseWriter = httptest.NewRecorder() wfe.UpdateRenewal(ctx, newRequestEvent(), responseWriter, req) test.AssertEquals(t, responseWriter.Code, http.StatusOK) } func TestOldTLSInbound(t *testing.T) { wfe, _, _ := setupWFE(t) req := &http.Request{ URL: &url.URL{Path: "/directory"}, Method: "GET", Header: http.Header(map[string][]string{ http.CanonicalHeaderKey("TLS-Version"): {"TLSv1"}, }), } responseWriter := httptest.NewRecorder() wfe.Handler(metrics.NoopRegisterer).ServeHTTP(responseWriter, req) test.AssertEquals(t, responseWriter.Code, http.StatusBadRequest) } func Test_sendError(t *testing.T) { features.Reset() wfe, _, _ := setupWFE(t) testResponse := httptest.NewRecorder() testErr := berrors.RateLimitError(0, "test") wfe.sendError(testResponse, &web.RequestEvent{Endpoint: "test"}, probs.RateLimited("test"), testErr) // Ensure a 0 value RetryAfter results in no Retry-After header. test.AssertEquals(t, testResponse.Header().Get("Retry-After"), "") // Ensure the Link header isn't populatsed. test.AssertEquals(t, testResponse.Header().Get("Link"), "") testErr = berrors.RateLimitError(time.Millisecond*500, "test") wfe.sendError(testResponse, &web.RequestEvent{Endpoint: "test"}, probs.RateLimited("test"), testErr) // Ensure a 500ms RetryAfter is rounded up to a 1s Retry-After header. test.AssertEquals(t, testResponse.Header().Get("Retry-After"), "1") // Ensure the Link header is populated. test.AssertEquals(t, testResponse.Header().Get("Link"), ";rel=\"help\"") // Clear headers for the next test. testResponse.Header().Del("Retry-After") testResponse.Header().Del("Link") testErr = berrors.RateLimitError(time.Millisecond*499, "test") wfe.sendError(testResponse, &web.RequestEvent{Endpoint: "test"}, probs.RateLimited("test"), testErr) // Ensure a 499ms RetryAfter results in no Retry-After header. test.AssertEquals(t, testResponse.Header().Get("Retry-After"), "") // Ensure the Link header isn't populatsed. test.AssertEquals(t, testResponse.Header().Get("Link"), "") }