Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NDES SCEP proxy backend #22542

Draft
wants to merge 11 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ dump-test-schema:
go run ./tools/dbutils ./server/datastore/mysql/schema.sql

test-go: dump-test-schema generate-mock
cat --number ./server/service/appconfig_test.go
go test -tags full,fts5,netgo ${GO_TEST_EXTRA_FLAGS_VAR} -parallel 8 -coverprofile=coverage.txt -covermode=atomic -coverpkg=github.com/fleetdm/fleet/v4/... ./cmd/... ./ee/... ./orbit/pkg/... ./orbit/cmd/orbit ./pkg/... ./server/... ./tools/...

analyze-go:
Expand Down
7 changes: 7 additions & 0 deletions cmd/fleet/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,13 @@ the way that the Fleet server works.
}
}

// SCEP proxy (for NDES, etc.)
if license.IsPremium() {
if err = service.RegisterSCEPProxy(rootMux, logger); err != nil {
initFatal(err, "setup SCEP proxy")
}
}

if config.Prometheus.BasicAuth.Username != "" && config.Prometheus.BasicAuth.Password != "" {
rootMux.Handle("/metrics", basicAuthHandler(
config.Prometheus.BasicAuth.Username,
Expand Down
3 changes: 2 additions & 1 deletion cmd/fleetctl/testdata/expectedGetConfigAppConfigJson.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,8 @@
"integrations": {
"jira": null,
"zendesk": null,
"google_calendar": null
"google_calendar": null,
"ndes_scep_proxy": null
},
"mdm": {
"apple_bm_terms_expired": false,
Expand Down
1 change: 1 addition & 0 deletions cmd/fleetctl/testdata/expectedGetConfigAppConfigYaml.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ spec:
integrations:
google_calendar: null
jira: null
ndes_scep_proxy: null
zendesk: null
mdm:
apple_bm_terms_expired: false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,8 @@
"integrations": {
"jira": null,
"zendesk": null,
"google_calendar": null
"google_calendar": null,
"ndes_scep_proxy": null
},
"update_interval": {
"osquery_detail": "1h0m0s",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ spec:
integrations:
google_calendar: null
jira: null
ndes_scep_proxy: null
zendesk: null
mdm:
apple_business_manager: null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ spec:
integrations:
google_calendar: null
jira: null
ndes_scep_proxy: null
zendesk: null
mdm:
apple_business_manager:
Expand Down
1 change: 1 addition & 0 deletions cmd/fleetctl/testdata/macosSetupExpectedAppConfigSet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ spec:
integrations:
google_calendar: null
jira: null
ndes_scep_proxy: null
zendesk: null
mdm:
apple_business_manager:
Expand Down
118 changes: 118 additions & 0 deletions ee/server/service/scep_proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package service

import (
"context"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"

"github.com/Azure/go-ntlmssp"
"github.com/fleetdm/fleet/v4/pkg/fleethttp"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
scepclient "github.com/fleetdm/fleet/v4/server/mdm/scep/client"
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
"github.com/go-kit/log"
"golang.org/x/text/encoding/unicode"
"golang.org/x/text/transform"
)

var _ scepserver.Service = (*scepProxyService)(nil)
var challengeRegex = regexp.MustCompile(`(?i)The enrollment challenge password is: <B> (?P<password>\S*)`)

const fullPasswordCache = "The password cache is full."

type scepProxyService struct {
// info logging is implemented in the service middleware layer.
debugLogger log.Logger
}

func (svc *scepProxyService) GetCACaps(_ context.Context) ([]byte, error) {
return nil, errors.New("not implemented")
}

func (svc *scepProxyService) GetCACert(_ context.Context, _ string) ([]byte, int, error) {
return nil, 0, errors.New("not implemented")
}

func (svc *scepProxyService) PKIOperation(_ context.Context, data []byte) ([]byte, error) {
return nil, errors.New("not implemented")
}

func (svc *scepProxyService) GetNextCACert(ctx context.Context) ([]byte, error) {
return nil, errors.New("not implemented")
}

// NewSCEPProxyService creates a new scep proxy service
func NewSCEPProxyService(logger log.Logger) scepserver.Service {
return &scepProxyService{
debugLogger: logger,
}
}

func ValidateNDESSCEPAdminURL(ctx context.Context, proxy fleet.NDESSCEPProxyIntegration) error {
adminURL, username, password := proxy.AdminURL, proxy.Username, proxy.Password
// Get the challenge from NDES
client := fleethttp.NewClient()
client.Transport = ntlmssp.Negotiator{
RoundTripper: fleethttp.NewTransport(),
}
req, err := http.NewRequest(http.MethodGet, adminURL, http.NoBody)
if err != nil {
return ctxerr.Wrap(ctx, err, "creating request")
}
req.SetBasicAuth(username, password)
resp, err := client.Do(req)
if err != nil {
return ctxerr.Wrap(ctx, err, "sending request")
}
if resp.StatusCode != http.StatusOK {
return ctxerr.New(ctx, fmt.Sprintf("unexpected status code: %d", resp.StatusCode))
}
// Make a transformer that converts MS-Win default to UTF8:
win16be := unicode.UTF16(unicode.BigEndian, unicode.IgnoreBOM)
// Make a transformer that is like win16be, but abides by BOM:
utf16bom := unicode.BOMOverride(win16be.NewDecoder())

// Make a Reader that uses utf16bom:
unicodeReader := transform.NewReader(resp.Body, utf16bom)
bodyText, err := io.ReadAll(unicodeReader)
if err != nil {
return ctxerr.Wrap(ctx, err, "reading response body")
}
htmlString := string(bodyText)

matches := challengeRegex.FindStringSubmatch(htmlString)
challenge := ""
if matches != nil {
challenge = matches[challengeRegex.SubexpIndex("password")]
}
if challenge == "" {
if strings.Contains(htmlString, fullPasswordCache) {
return ctxerr.New(ctx,
"the password cache is full; please increase the number of cached passwords in NDES; by default, NDES caches 5 passwords and they expire 60 minutes after they are created")
}
return ctxerr.New(ctx,
"could not retrieve the enrollment challenge password; invalid admin URL or credentials; please correct and try again")
}
return nil
}

func ValidateNDESSCEPURL(ctx context.Context, proxy fleet.NDESSCEPProxyIntegration, logger log.Logger) error {
client, err := scepclient.New(proxy.URL, logger)
if err != nil {
return ctxerr.Wrap(ctx, err, "creating SCEP client; invalid SCEP URL; please correct and try again")
}

certs, _, err := client.GetCACert(ctx, "")
if err != nil {
return ctxerr.Wrap(ctx, err, "could not retrieve CA certificate from SCEP URL; invalid SCEP URL; please correct and try again")
}
if len(certs) == 0 {
return ctxerr.New(ctx, "SCEP URL did not return a CA certificate")
}
return nil
}
147 changes: 147 additions & 0 deletions ee/server/service/scep_proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
package service

import (
"context"
"crypto/x509"
"encoding/binary"
"net/http"
"net/http/httptest"
"os"
"syscall"
"testing"
"unicode/utf16"

"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
filedepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot/file"
scepserver "github.com/fleetdm/fleet/v4/server/mdm/scep/server"
kitlog "github.com/go-kit/log"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestValidateNDESSCEPAdminURL(t *testing.T) {
t.Parallel()

var returnPage func() []byte
returnStatus := http.StatusOK
ndesAdminServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(returnStatus)
if returnStatus == http.StatusOK {
_, err := w.Write(returnPage())
require.NoError(t, err)
}
}))
t.Cleanup(ndesAdminServer.Close)

proxy := fleet.NDESSCEPProxyIntegration{
AdminURL: ndesAdminServer.URL,
Username: "admin",
Password: "password",
}

returnStatus = http.StatusNotFound
err := ValidateNDESSCEPAdminURL(context.Background(), proxy)
assert.ErrorContains(t, err, "unexpected status code")

// We need to convert the HTML page to UTF-16 encoding, which is used by Windows servers
returnPageFromFile := func(path string) []byte {
dat, err := os.ReadFile(path)
require.NoError(t, err)
datUTF16, err := utf16FromString(string(dat))
require.NoError(t, err)
byteData := make([]byte, len(datUTF16)*2)
for i, v := range datUTF16 {
binary.BigEndian.PutUint16(byteData[i*2:], v)
}
return byteData
}

// Catch ths issue when NDES password cache is full
returnStatus = http.StatusOK
returnPage = func() []byte {
return returnPageFromFile("./testdata/mscep_admin_cache_full.html")
}
err = ValidateNDESSCEPAdminURL(context.Background(), proxy)
assert.ErrorContains(t, err, "the password cache is full")

// Nothing returned
returnPage = func() []byte {
return []byte{}
}
err = ValidateNDESSCEPAdminURL(context.Background(), proxy)
assert.ErrorContains(t, err, "could not retrieve the enrollment challenge password")

// All good
returnPage = func() []byte {
return returnPageFromFile("./testdata/mscep_admin_password.html")
}
err = ValidateNDESSCEPAdminURL(context.Background(), proxy)
assert.NoError(t, err)
}

func TestValidateNDESSCEPURL(t *testing.T) {
t.Parallel()
srv := newSCEPServer(t)

proxy := fleet.NDESSCEPProxyIntegration{
URL: srv.URL + "/scep",
}
err := ValidateNDESSCEPURL(context.Background(), proxy, kitlog.NewNopLogger())
assert.NoError(t, err)

proxy.URL = srv.URL + "/bozo"
err = ValidateNDESSCEPURL(context.Background(), proxy, kitlog.NewNopLogger())
assert.ErrorContains(t, err, "could not retrieve CA certificate")

}

// utf16FromString returns the UTF-16 encoding of the UTF-8 string s, with a terminating NUL added.
// If s contains a NUL byte at any location, it returns (nil, syscall.EINVAL).
func utf16FromString(s string) ([]uint16, error) {
for i := 0; i < len(s); i++ {
if s[i] == 0 {
return nil, syscall.EINVAL
}
}
return utf16.Encode([]rune(s + "\x00")), nil
}

func newSCEPServer(t *testing.T) *httptest.Server {
var err error
var certDepot depot.Depot // cert storage
depotPath := "./testdata/testca"
t.Cleanup(func() {
_ = os.Remove("./testdata/testca/serial")
_ = os.Remove("./testdata/testca/index.txt")
})
certDepot, err = filedepot.NewFileDepot(depotPath)
if err != nil {
t.Fatal(err)
}
certDepot = &noopDepot{certDepot}
crt, key, err := certDepot.CA([]byte{})
if err != nil {
t.Fatal(err)
}
var svc scepserver.Service // scep service
svc, err = scepserver.NewService(crt[0], key, scepserver.NopCSRSigner())
if err != nil {
t.Fatal(err)
}
logger := kitlog.NewNopLogger()
e := scepserver.MakeServerEndpoints(svc)
scepHandler := scepserver.MakeHTTPHandler(e, svc, logger)
r := mux.NewRouter()
r.Handle("/scep", scepHandler)
server := httptest.NewServer(r)
t.Cleanup(server.Close)
return server
}

type noopDepot struct{ depot.Depot }

func (d *noopDepot) Put(_ string, _ *x509.Certificate) error {
return nil
}
1 change: 1 addition & 0 deletions ee/server/service/testdata/mscep_admin_cache_full.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<HTML><Head><Meta HTTP-Equiv="Content-Type" Content="text/html; charset=UTF-8"><Title>Network Device Enrollment Service</Title></Head><Body BgColor=#FFFFFF><Font ID=locPageFont Face="Arial"><Table Border=0 CellSpacing=0 CellPadding=4 Width=100% BgColor=#008080><TR><TD><Font ID=locPageTitleFont Face="Arial" Size=-1 Color=#FFFFFF><LocID ID=locMSCertSrv>Network Device Enrollment Service</LocID></Font></TD></TR></Table><P ID=locPageTitle> Network Device Enrollment Service allows you to obtain certificates for routers or other network devices using the Simple Certificate Enrollment Protocol (SCEP). </P><P> The password cache is full. <P> Network Device Enrollment Service stores unused password for later use. By default, passwords are stored for 60 minutes.<P>Use one of the existing passwords. If you cannot use an existing password: <UL> <LI> Wait until one or more existing passwords expire (by default passwords expire 60 minutes after they are created). </LI> <LI> Restart Internet Information Services (IIS). </LI> <LI> Configure the service to cache more than 5 passwords. </LI> </UL> </P> <P ID=locPageDesc> For more information see <A HREF=http://go.microsoft.com/fwlink/?LinkId=67852>Using Network Device Enrollment Service </A>. </P></Font></Body></HTML>
1 change: 1 addition & 0 deletions ee/server/service/testdata/mscep_admin_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<HTML><Head><Meta HTTP-Equiv="Content-Type" Content="text/html; charset=UTF-8"><Title>Network Device Enrollment Service</Title></Head><Body BgColor=#FFFFFF><Font ID=locPageFont Face="Arial"><Table Border=0 CellSpacing=0 CellPadding=4 Width=100% BgColor=#008080><TR><TD><Font ID=locPageTitleFont Face="Arial" Size=-1 Color=#FFFFFF><LocID ID=locMSCertSrv>Network Device Enrollment Service</LocID></Font></TD></TR></Table><P ID=locPageTitle> Network Device Enrollment Service allows you to obtain certificates for routers or other network devices using the Simple Certificate Enrollment Protocol (SCEP). </P><P> To complete certificate enrollment for your network device you will need the following information: <P> The thumbprint (hash value) for the CA certificate is: <B> A656FA66 AB12B433 A2DA5CF7 CC153D9A </B> <P> The enrollment challenge password is: <B> 8CE317021F690069 </B> <P> This password can be used only once and will expire within 60 minutes. <P> Each enrollment requires a new challenge password. You can refresh this web page to obtain a new challenge password. </P> <P ID=locPageDesc> For more information see <A HREF=http://go.microsoft.com/fwlink/?LinkId=67852>Using Network Device Enrollment Service </A>. </P></Font></Body></HTML>
Loading
Loading