From 2fbcff1d4006d0bdb2aebce9e50121c00d4f59f8 Mon Sep 17 00:00:00 2001 From: Seo Suchan Date: Thu, 19 Feb 2026 18:59:01 +0900 Subject: [PATCH 1/4] fix enroll validcert test it had missing a / in path building so it always errored out before Signed-off-by: Seo Suchan --- http_test.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/http_test.go b/http_test.go index fb3ad0d..6263e8e 100644 --- a/http_test.go +++ b/http_test.go @@ -126,13 +126,14 @@ func TestSimpleEnrollRequiresValidCert(t *testing.T) { kp := svc.createTlsKP(t, tc.ctx, "enrollRequiresValid") - url := tc.srv.URL + ".well-known/est/simpleenroll" + url := tc.srv.URL + "/.well-known/est/simpleenroll" client := tc.srv.Client() transport := client.Transport.(*http.Transport) transport.TLSClientConfig.Certificates = []tls.Certificate{*kp} - _, err := client.Post(url, "application/pkcs10", bytes.NewBuffer([]byte{})) - require.NotNil(t, err) + res, err := client.Post(url, "application/pkcs10", bytes.NewBuffer([]byte{})) + require.GreaterOrEqual(t, res.StatusCode, 400, "Server accepted invalid") + require.Nil(t, err) }) } From a37719298444fa9b42bd15f7517f7468f3130936 Mon Sep 17 00:00:00 2001 From: Seo Suchan Date: Fri, 20 Feb 2026 08:22:09 +0900 Subject: [PATCH 2/4] remove surplus parameter form cert templet they aren't used by x509.createcertificate add test for cert key match with csr requested Signed-off-by: Seo Suchan --- service.go | 4 ---- service_test.go | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/service.go b/service.go index bddbad9..732de0a 100644 --- a/service.go +++ b/service.go @@ -251,11 +251,7 @@ func (s Service) signCsr(ctx context.Context, csr *x509.CertificateRequest) ([]b NotBefore: now, NotAfter: notAfter, RawSubject: csr.RawSubject, - Signature: csr.Signature, - SignatureAlgorithm: csr.SignatureAlgorithm, - PublicKeyAlgorithm: csr.PublicKeyAlgorithm, Issuer: s.ca.Subject, - PublicKey: csr.PublicKey, BasicConstraintsValid: true, IsCA: false, KeyUsage: ku, diff --git a/service_test.go b/service_test.go index 4090079..984afe2 100644 --- a/service_test.go +++ b/service_test.go @@ -130,6 +130,8 @@ func TestService_signCsr(t *testing.T) { require.Nil(t, err) cert := p7.Certificates[0] require.Equal(t, cn, cert.Subject.CommonName) + certpub := cert.PublicKey.(*ecdsa.PublicKey) + require.True(t, certpub.Equal(key.Public()), "cert have different public key from request CSR") // X509 Key usage must be: DigitalSignature csr.Extensions[0].Value = []byte{3, 2, 7, 120} From f2a9860e4ccef924465a22ecd5c1bc3041195ff0 Mon Sep 17 00:00:00 2001 From: Seo Suchan Date: Fri, 20 Feb 2026 08:22:56 +0900 Subject: [PATCH 3/4] Feature: support /serverkeygen endpoint for entropy limited clients that can't trust local random server will create same type of key with CA don't support out of band key encryption Signed-off-by: Seo Suchan --- README.md | 2 +- cmd/main.go | 3 ++- http_handlers.go | 50 ++++++++++++++++++++++++++++++++++++++++++ http_test.go | 54 ++++++++++++++++++++++++++++++++++++++++++++++ service.go | 56 ++++++++++++++++++++++++++++++++++++++++++++---- service_test.go | 31 +++++++++++++++++++++++++-- 6 files changed, 188 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 5880b63..9407c76 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ As this project's primary aim is handling device certificate renewal, optional features of the RFC have been omitted including: * 4.3 - CMC -* 4.4 - Server side key generation +* 4.4 - server side key generation is supported partially, without the key encryption. * 4.5 - CSR attributes ## Contributing diff --git a/cmd/main.go b/cmd/main.go index 98ec13f..3ca2a7e 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -36,6 +36,7 @@ func main() { port := flag.Int("port", 8443, "Port to listen on") certDuration := flag.Duration("cert-duration", time.Hour*24*365*3, "How long new certs should be valid for. e.g. such as '1.5h' or '2h45m'. 3 years is default") clientCas := flag.String("client-cas", "", "PEM encoded list of device CA's to allow. The device must present a certificate signed by a CA in this list or the `ca-cert` to authenticate") + serverKeygen := flag.Bool("serverkeygen", false, "Whether server should enable server-side keygen (default disabled)") for _, opt := range required { flag.StringVar(&opt.value, opt.name, "", opt.help) @@ -86,7 +87,7 @@ func main() { log.Fatal().Err(err).Msg("Unable to create tls cert handler") } - svcHandler := est.NewStaticServiceHandler(est.NewService(rootCerts, caCert, caKey, *certDuration)) + svcHandler := est.NewStaticServiceHandler(est.NewService(rootCerts, caCert, caKey, *certDuration, *serverKeygen)) e := echo.New() s := http.Server{ diff --git a/http_handlers.go b/http_handlers.go index aba0b41..48160c8 100644 --- a/http_handlers.go +++ b/http_handlers.go @@ -1,10 +1,13 @@ package est import ( + "bytes" "errors" "fmt" "io" + "mime/multipart" "net/http" + "net/textproto" "github.com/labstack/echo/v4" ) @@ -73,6 +76,53 @@ func RegisterEchoHandlers(svcHandler ServiceHandler, e *echo.Echo) { return c.Blob(http.StatusOK, "application/pkcs7-mime; smime-type=certs-only", bytes) }) + e.POST("/.well-known/est/serverkeygen", func(c echo.Context) error { + svc, err := svcHandler.GetService(c.Request().Context(), c.Request().TLS.ServerName) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + if !svc.allowServerKeygen { + return c.String(http.StatusBadRequest, "this server does not allow server-side keygen") + } + reqbytes, err := validateRequest(svc, c) + if err != nil || reqbytes == nil { // validateRequest failed and sent the response + return err + } + peerCerts := c.Request().TLS.PeerCertificates + crt, pkey, err := svc.ServerKeygen(c.Request().Context(), reqbytes, peerCerts[0]) + if err != nil { + if errors.Is(err, ErrEst) { + return c.String(http.StatusBadRequest, err.Error()) + } + return c.String(http.StatusInternalServerError, err.Error()) + } + mw := new(bytes.Buffer) + mpWriter := multipart.NewWriter(mw) + + crtHeader := make(textproto.MIMEHeader) + crtHeader.Set("Content-Type", "application/pkcs7-mime; smime-type=certs-only") + crtHeader.Set("Content-Transfer-Encoding", "base64") + partC, _ := mpWriter.CreatePart(crtHeader) + _, err = partC.Write(crt) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + + keyHeader := make(textproto.MIMEHeader) + keyHeader.Set("Content-Type", "application/pkcs8") + keyHeader.Set("Content-Transfer-Encoding", "base64") + partK, _ := mpWriter.CreatePart(keyHeader) + _, err = partK.Write(pkey) + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + err = mpWriter.Close() + if err != nil { + return c.String(http.StatusInternalServerError, err.Error()) + } + contentType := "multipart/mixed; boundary=" + mpWriter.Boundary() + return c.Blob(http.StatusOK, contentType, mw.Bytes()) + }) } // validateRequest checks that the client has provided a client cert (via mTLS) diff --git a/http_test.go b/http_test.go index 6263e8e..7ae3694 100644 --- a/http_test.go +++ b/http_test.go @@ -7,6 +7,8 @@ import ( "crypto/x509" "encoding/base64" "io" + "mime" + "mime/multipart" "net" "net/http" "net/http/httptest" @@ -215,6 +217,58 @@ func TestSimpleReEnroll(t *testing.T) { }) } +func TestServerKeygen(t *testing.T) { + WithEstServer(t, func(tc testClient) { + cn := random.String(10) + kp := tc.svc.createTlsKP(t, tc.ctx, cn) + rc, data := tc.POST(t, "/.well-known/est/serverkeygen", []byte{}, kp) + require.Equal(t, 400, rc, string(data)) + require.Equal(t, "The CSR could not be decoded: asn1: syntax error: sequence truncated", string(data)) + + var boundary string + var extract responseChecker = func(t *testing.T, res *http.Response) { + mediaType, params, err := mime.ParseMediaType(res.Header.Get("Content-Type")) + boundary = params["boundary"] + require.Equal(t, "multipart/mixed", mediaType) + require.Nil(t, err) + } // ugly but boundary is in header but response is already read by POST() + + _, csr := createB64CsrDer(t, cn) + rc, buf := tc.POST(t, "/.well-known/est/serverkeygen", csr, kp, extract) + require.Equal(t, 200, rc, buf) + + // multipart structure + mpr := multipart.NewReader(bytes.NewBuffer(buf), boundary) + + // certificate part + part, err := mpr.NextPart() + require.Nil(t, err, string(data)) + require.Equal(t, "application/pkcs7-mime; smime-type=certs-only", part.Header.Get("Content-Type"), "Wrong Content type for first part") + bytebuf, err := io.ReadAll(part) + require.Nil(t, err) + cert, err := base64.StdEncoding.DecodeString(string(bytebuf)) + require.Nil(t, err) + p7c, err := pkcs7.Parse(cert) + require.Nil(t, err) + require.Equal(t, 1, len(p7c.Certificates), "wrong amout of certs") + + // private key part + part, err = mpr.NextPart() + require.Nil(t, err, string(data)) + require.Equal(t, "application/pkcs8", part.Header.Get("Content-Type"), "Wrong Content type for first part") + bytebuf, err = io.ReadAll(part) + require.Nil(t, err) + keyb, err := base64.StdEncoding.DecodeString(string(bytebuf)) + require.Nil(t, err) + _, err = x509.ParsePKCS8PrivateKey(keyb) + require.Nil(t, err) + + // test rejecting when service not allow serverkeygen + rc, buf = tc.POST(t, "/.well-known/est/serverkeygen", csr, kp, extract) + require.Equal(t, 200, rc, buf) + }) +} + func (s Service) createTlsKP(t *testing.T, ctx context.Context, cn string) *tls.Certificate { key, csrBytes := createB64CsrDer(t, cn) bytes, err := s.Enroll(ctx, csrBytes) diff --git a/service.go b/service.go index 732de0a..7d52afc 100644 --- a/service.go +++ b/service.go @@ -4,7 +4,9 @@ import ( "bytes" "context" "crypto" + "crypto/ecdsa" "crypto/rand" + "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/asn1" @@ -88,7 +90,7 @@ func NewStaticServiceHandler(svc Service) ServiceHandler { // Optional APIs are not implemented including: // // 4.3 - cmc -// 4.4 - server side key generation +// 4.4 - server side key generation is supported partially, without the key encryption. // 4.5 - CSR attributes type Service struct { // Root CAs for a Factory @@ -97,17 +99,19 @@ type Service struct { ca *x509.Certificate key crypto.Signer - certDuration time.Duration + allowServerKeygen bool + certDuration time.Duration } // NewService creates an EST7030 API for a Factory -func NewService(rootCa []*x509.Certificate, ca *x509.Certificate, key crypto.Signer, certDuration time.Duration) Service { +func NewService(rootCa []*x509.Certificate, ca *x509.Certificate, key crypto.Signer, certDuration time.Duration, allowServerKeygen bool) Service { return Service{ rootCa: rootCa, ca: ca, key: key, - certDuration: certDuration, + allowServerKeygen: allowServerKeygen, + certDuration: certDuration, } } @@ -182,6 +186,50 @@ func (s Service) ReEnroll(ctx context.Context, csrBytes []byte, curCert *x509.Ce return s.signCsr(ctx, csr) } +// ServerKeygen performs EST7030 enrollment operation with server generated key as per +// https://www.rfc-editor.org/rfc/rfc7030.html#section-4.1.1 +// Errors can be generic errors or of the type EstError. +// server won't use additional encryption independent from TLS. +func (s Service) ServerKeygen(ctx context.Context, csrBytes []byte, curCert *x509.Certificate) ([]byte, []byte, error) { + if !s.allowServerKeygen { + return nil, nil, fmt.Errorf("server not allow serverside keygen") + } + originalCsr, err := s.loadCsr(ctx, csrBytes) + if err != nil { + return nil, nil, err + } + var newCertKey crypto.Signer + switch capub := s.ca.PublicKey.(type) { + case *rsa.PublicKey: + newCertKey, err = rsa.GenerateKey(rand.Reader, 2048) + case *ecdsa.PublicKey: + newCertKey, err = ecdsa.GenerateKey(capub.Curve, rand.Reader) + default: + return nil, nil, fmt.Errorf("nullkeytype") + } + + if err != nil { + return nil, nil, err + } + // re-key CSR, only add thing signcsr will read + // make new variable because go didn't like modifiing Raw fields after creation + // no need to actually create csr bytes, just fill needed for s.signer + newCsrTemplate := x509.CertificateRequest{ + SignatureAlgorithm: s.ca.SignatureAlgorithm, + RawSubject: originalCsr.RawSubject, + PublicKey: newCertKey.Public(), + Extensions: originalCsr.Extensions, + } + newCertKeyByte, _ := x509.MarshalPKCS8PrivateKey(newCertKey) + + cert, err := s.signCsr(ctx, &newCsrTemplate) + if err != nil { + return nil, nil, err + } + + return cert, []byte(base64.StdEncoding.EncodeToString(newCertKeyByte)), nil +} + // loadCsr parses the certifcate signing request based on rules of // https://www.rfc-editor.org/rfc/rfc7030.html#section-4.2.1 // - content is a base64 encoded certificate signing request diff --git a/service_test.go b/service_test.go index 984afe2..d3cb84f 100644 --- a/service_test.go +++ b/service_test.go @@ -71,7 +71,7 @@ func createService(t *testing.T) Service { cert, err := x509.ParseCertificate(der) require.Nil(t, err) - return Service{[]*x509.Certificate{cert}, cert, key, time.Hour * 24} + return Service{[]*x509.Certificate{cert}, cert, key, true, time.Hour * 24} } func TestService_CA(t *testing.T) { @@ -118,7 +118,7 @@ func TestService_signCsr(t *testing.T) { cn := random.String(12) s := createService(t) - _, csrBytes := createB64CsrDer(t, cn) + key, csrBytes := createB64CsrDer(t, cn) csr, err := s.loadCsr(ctx, csrBytes) require.Nil(t, err) @@ -198,3 +198,30 @@ func TestService_ReEnroll(t *testing.T) { cert := p7.Certificates[0] require.Equal(t, cn, cert.Subject.CommonName) } + +func TestService_ServerKeygen(t *testing.T) { + log := InitLogger("") + ctx := CtxWithLog(context.TODO(), log) + + s := createService(t) + cn := random.String(12) + _, csrBytes := createB64CsrDer(t, cn) + bytes, key, err := s.ServerKeygen(ctx, csrBytes, nil) + require.Nil(t, err) + require.NotNil(t, key) + + bytes, err = base64.StdEncoding.DecodeString(string(bytes)) + require.Nil(t, err) + p7, err := pkcs7.Parse(bytes) + require.Nil(t, err) + cert := p7.Certificates[0] + require.Equal(t, cn, cert.Subject.CommonName) + key, err = base64.StdEncoding.DecodeString(string(key)) + require.Nil(t, err) + _, err = x509.ParsePKCS8PrivateKey(key) + require.Nil(t, err) + + s.allowServerKeygen = false + _, _, err = s.ServerKeygen(ctx, csrBytes, nil) + require.NotNil(t, err) +} From 8789dce874a362bb38549bfcf0d75c4cc04a039f Mon Sep 17 00:00:00 2001 From: orangepizza Date: Wed, 25 Feb 2026 10:06:22 +0900 Subject: [PATCH 4/4] Update service.go comment Co-authored-by: vkhoroz --- service.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/service.go b/service.go index 7d52afc..95e89fb 100644 --- a/service.go +++ b/service.go @@ -211,9 +211,8 @@ func (s Service) ServerKeygen(ctx context.Context, csrBytes []byte, curCert *x50 if err != nil { return nil, nil, err } - // re-key CSR, only add thing signcsr will read - // make new variable because go didn't like modifiing Raw fields after creation - // no need to actually create csr bytes, just fill needed for s.signer + // Re-key CSR, only add things that signCsr needs. + // Make a new variable because Golang does not support modifying the CSR object fields directly. newCsrTemplate := x509.CertificateRequest{ SignatureAlgorithm: s.ca.SignatureAlgorithm, RawSubject: originalCsr.RawSubject,