package integration import ( "context" "crypto/tls" "crypto/x509" "encoding/json" "fmt" "net" "net/http" "net/http/httptest" "path/filepath" "sync" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.step.sm/crypto/jose" "go.step.sm/crypto/keyutil" "go.step.sm/crypto/minica" "go.step.sm/crypto/pemutil" "go.step.sm/crypto/randutil" "go.step.sm/crypto/x509util" "github.com/smallstep/certificates/api" "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" "github.com/smallstep/certificates/ca" "github.com/smallstep/certificates/ca/client" "github.com/smallstep/certificates/errs" ) // reservePort "reserves" a TCP port by opening a listener on a random // port and immediately closing it. The port can then be assumed to be // available for running a server on. func reservePort(t *testing.T) (host, port string) { t.Helper() l, err := net.Listen("tcp", ":0") require.NoError(t, err) address := l.Addr().String() err = l.Close() require.NoError(t, err) host, port, err = net.SplitHostPort(address) require.NoError(t, err) return } func Test_reflectRequestID(t *testing.T) { dir := t.TempDir() m, err := minica.New(minica.WithName("Step E2E")) require.NoError(t, err) rootFilepath := filepath.Join(dir, "root.crt") _, err = pemutil.Serialize(m.Root, pemutil.WithFilename(rootFilepath)) require.NoError(t, err) intermediateCertFilepath := filepath.Join(dir, "intermediate.crt") _, err = pemutil.Serialize(m.Intermediate, pemutil.WithFilename(intermediateCertFilepath)) require.NoError(t, err) intermediateKeyFilepath := filepath.Join(dir, "intermediate.key") _, err = pemutil.Serialize(m.Signer, pemutil.WithFilename(intermediateKeyFilepath)) require.NoError(t, err) // get a random address to listen on and connect to; currently no nicer way to get one before starting the server // TODO(hs): find/implement a nicer way to expose the CA URL, similar to how e.g. httptest.Server exposes it? host, port := reservePort(t) authorizingSrv := newAuthorizingServer(t, m) defer authorizingSrv.Close() authorizingSrv.StartTLS() password := []byte("1234") jwk, jwe, err := jose.GenerateDefaultKeyPair(password) require.NoError(t, err) encryptedKey, err := jwe.CompactSerialize() require.NoError(t, err) prov := &provisioner.JWK{ ID: "jwk", Name: "jwk", Type: "JWK", Key: jwk, EncryptedKey: encryptedKey, Claims: &config.GlobalProvisionerClaims, Options: &provisioner.Options{ Webhooks: []*provisioner.Webhook{ { ID: "webhook", Name: "webhook-test", URL: fmt.Sprintf("%s/authorize", authorizingSrv.URL), Kind: "AUTHORIZING", CertType: "X509", }, }, }, } err = prov.Init(provisioner.Config{}) require.NoError(t, err) cfg := &config.Config{ Root: []string{rootFilepath}, IntermediateCert: intermediateCertFilepath, IntermediateKey: intermediateKeyFilepath, Address: net.JoinHostPort(host, port), // reuse the address that was just "reserved" DNSNames: []string{"127.0.0.1", "[::1]", "localhost"}, AuthorityConfig: &config.AuthConfig{ AuthorityID: "stepca-test", DeploymentType: "standalone-test", Provisioners: provisioner.List{prov}, }, Logger: json.RawMessage(`{"format": "text"}`), } c, err := ca.New(cfg) require.NoError(t, err) // instantiate a client for the CA running at the random address caClient, err := ca.NewClient( fmt.Sprintf("https://localhost:%s", port), ca.WithRootFile(rootFilepath), ) require.NoError(t, err) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() err = c.Run() require.ErrorIs(t, err, http.ErrServerClosed) }() // require OK health response as the baseline ctx := context.Background() healthResponse, err := caClient.HealthWithContext(ctx) require.NoError(t, err) if assert.NotNil(t, healthResponse) { require.Equal(t, "ok", healthResponse.Status) } // expect an error when retrieving an invalid root rootResponse, err := caClient.RootWithContext(ctx, "invalid") var firstErr *errs.Error if assert.ErrorAs(t, err, &firstErr) { assert.Equal(t, 404, firstErr.StatusCode()) assert.Equal(t, "The requested resource could not be found. Please see the certificate authority logs for more info.", firstErr.Err.Error()) assert.NotEmpty(t, firstErr.RequestID) // TODO: include the below error in the JSON? It's currently only output to the CA logs. Also see https://github.com/smallstep/certificates/pull/759 //assert.Equal(t, "/root/invalid was not found: certificate with fingerprint invalid was not found", apiErr.Msg) } assert.Nil(t, rootResponse) // expect an error when retrieving an invalid root and provided request ID rootResponse, err = caClient.RootWithContext(client.NewRequestIDContext(ctx, "reqID"), "invalid") var secondErr *errs.Error if assert.ErrorAs(t, err, &secondErr) { assert.Equal(t, 404, secondErr.StatusCode()) assert.Equal(t, "The requested resource could not be found. Please see the certificate authority logs for more info.", secondErr.Err.Error()) assert.Equal(t, "reqID", secondErr.RequestID) } assert.Nil(t, rootResponse) // prepare a Sign request subject := "test" decryptedJWK := decryptPrivateKey(t, jwe, password) ott := generateOTT(t, decryptedJWK, subject) signer, err := keyutil.GenerateDefaultSigner() require.NoError(t, err) csr, err := x509util.CreateCertificateRequest(subject, []string{subject}, signer) require.NoError(t, err) // perform the Sign request using the OTT and CSR signResponse, err := caClient.SignWithContext(client.NewRequestIDContext(ctx, "signRequestID"), &api.SignRequest{ CsrPEM: api.CertificateRequest{CertificateRequest: csr}, OTT: ott, NotAfter: api.NewTimeDuration(time.Now().Add(1 * time.Hour)), NotBefore: api.NewTimeDuration(time.Now().Add(-1 * time.Hour)), }) assert.NoError(t, err) // assert a certificate was returned for the subject "test" if assert.NotNil(t, signResponse) { assert.Len(t, signResponse.CertChainPEM, 2) cert, err := x509.ParseCertificate(signResponse.CertChainPEM[0].Raw) assert.NoError(t, err) if assert.NotNil(t, cert) { assert.Equal(t, "test", cert.Subject.CommonName) assert.Contains(t, cert.DNSNames, "test") } } // done testing; stop and wait for the server to quit err = c.Stop() require.NoError(t, err) wg.Wait() } func decryptPrivateKey(t *testing.T, jwe *jose.JSONWebEncryption, pass []byte) *jose.JSONWebKey { t.Helper() d, err := jwe.Decrypt(pass) require.NoError(t, err) jwk := &jose.JSONWebKey{} err = json.Unmarshal(d, jwk) require.NoError(t, err) return jwk } func generateOTT(t *testing.T, jwk *jose.JSONWebKey, subject string) string { t.Helper() now := time.Now() keyID, err := jose.Thumbprint(jwk) require.NoError(t, err) opts := new(jose.SignerOptions).WithType("JWT").WithHeader("kid", keyID) signer, err := jose.NewSigner(jose.SigningKey{Key: jwk.Key}, opts) require.NoError(t, err) id, err := randutil.ASCII(64) require.NoError(t, err) cl := struct { jose.Claims SANS []string `json:"sans"` }{ Claims: jose.Claims{ ID: id, Subject: subject, Issuer: "jwk", NotBefore: jose.NewNumericDate(now), Expiry: jose.NewNumericDate(now.Add(time.Minute)), Audience: []string{"https://127.0.0.1/1.0/sign"}, }, SANS: []string{subject}, } raw, err := jose.Signed(signer).Claims(cl).CompactSerialize() require.NoError(t, err) return raw } func newAuthorizingServer(t *testing.T, mca *minica.CA) *httptest.Server { t.Helper() key, err := keyutil.GenerateDefaultSigner() require.NoError(t, err) csr, err := x509util.CreateCertificateRequest("127.0.0.1", []string{"127.0.0.1"}, key) require.NoError(t, err) crt, err := mca.SignCSR(csr) require.NoError(t, err) srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if assert.Equal(t, "signRequestID", r.Header.Get("X-Request-Id")) { json.NewEncoder(w).Encode(struct{ Allow bool }{Allow: true}) w.WriteHeader(http.StatusOK) return } w.WriteHeader(http.StatusBadRequest) })) trustedRoots := x509.NewCertPool() trustedRoots.AddCert(mca.Root) srv.TLS = &tls.Config{ Certificates: []tls.Certificate{ { Certificate: [][]byte{crt.Raw, mca.Intermediate.Raw}, PrivateKey: key, Leaf: crt, }, }, ClientCAs: trustedRoots, ClientAuth: tls.RequireAndVerifyClientCert, ServerName: "localhost", } return srv }