From 239d6b0d40b11ec91a2b1bcc7b2f362e0bd7b3dc Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Tue, 30 Jan 2024 15:40:07 -0800 Subject: [PATCH 1/4] Split out UserAuthenticate to its own implementation, add tests, and CLI option. --- cmd/nanomdm/main.go | 6 ++- service/nanomdm/service.go | 57 +++++++--------------------- service/nanomdm/ua.go | 78 ++++++++++++++++++++++++++++++++++++++ service/nanomdm/ua_test.go | 62 ++++++++++++++++++++++++++++++ service/service.go | 7 +++- storage/storage.go | 6 ++- 6 files changed, 170 insertions(+), 46 deletions(-) create mode 100644 service/nanomdm/ua.go create mode 100644 service/nanomdm/ua_test.go diff --git a/cmd/nanomdm/main.go b/cmd/nanomdm/main.go index 01fecdc..b9ef665 100644 --- a/cmd/nanomdm/main.go +++ b/cmd/nanomdm/main.go @@ -71,6 +71,7 @@ func main() { flRetro = flag.Bool("retro", false, "Allow retroactive certificate-authorization association") flDMURLPfx = flag.String("dm", "", "URL to send Declarative Management requests to") flAuthProxy = flag.String("auth-proxy-url", "", "Reverse proxy URL target for MDM-authenticated HTTP requests") + flUAZLChal = flag.Bool("", false, "reply with zero-length DigestChallenge for UserAuthenticate") ) flag.Parse() @@ -110,7 +111,10 @@ func main() { } // create 'core' MDM service - nanoOpts := []nanomdm.Option{nanomdm.WithLogger(logger.With("service", "nanomdm"))} + nanoOpts := []nanomdm.Option{ + nanomdm.WithLogger(logger.With("service", "nanomdm")), + nanomdm.WithUserAuthenticate(nanomdm.NewUAService(mdmStorage, *flUAZLChal)), + } if *flDMURLPfx != "" { logger.Debug("msg", "declarative management setup", "url", *flDMURLPfx) dm, err := nanomdm.NewDeclarativeManagementHTTPCaller(*flDMURLPfx, http.DefaultClient) diff --git a/service/nanomdm/service.go b/service/nanomdm/service.go index b6643d8..2925ef0 100644 --- a/service/nanomdm/service.go +++ b/service/nanomdm/service.go @@ -4,7 +4,6 @@ package nanomdm import ( "errors" "fmt" - "net/http" "github.com/micromdm/nanomdm/log" "github.com/micromdm/nanomdm/log/ctxlog" @@ -19,16 +18,11 @@ type Service struct { normalizer func(e *mdm.Enrollment) *mdm.EnrollID store storage.ServiceStore - // By default the UserAuthenticate message will be rejected (410 - // response). If this is set true then a static zero-length - // digest challenge will be supplied to the first UserAuthenticate - // check-in message. See the Discussion section of - // https://developer.apple.com/documentation/devicemanagement/userauthenticate - sendEmptyDigestChallenge bool - storeRejectedUserAuth bool - // Declarative Management dm service.DeclarativeManagement + + // UserAuthenticate processor + ua service.UserAuthenticate } // normalize generates enrollment IDs that are used by other @@ -72,6 +66,13 @@ func WithDeclarativeManagement(dm service.DeclarativeManagement) Option { } } +// WithUserAuthenticate configures a UserAuthenticate check-in message handler. +func WithUserAuthenticate(ua service.UserAuthenticate) Option { + return func(s *Service) { + s.ua = ua + } +} + // New returns a new NanoMDM main service. func New(store storage.ServiceStore, opts ...Option) *Service { nanomdm := &Service{ @@ -144,45 +145,15 @@ func (s *Service) CheckOut(r *mdm.Request, message *mdm.CheckOut) error { return s.store.Disable(r) } -const emptyDigestChallenge = ` - - - - DigestChallenge - - -` - -var emptyDigestChallengeBytes = []byte(emptyDigestChallenge) - +// UserAuthenticate Check-in message implementation func (s *Service) UserAuthenticate(r *mdm.Request, message *mdm.UserAuthenticate) ([]byte, error) { if err := s.setupRequest(r, &message.Enrollment); err != nil { return nil, err } - logger := ctxlog.Logger(r.Context, s.logger) - if s.sendEmptyDigestChallenge || s.storeRejectedUserAuth { - if err := s.store.StoreUserAuthenticate(r, message); err != nil { - return nil, err - } - } - // if the DigestResponse is empty then this is the first (of two) - // UserAuthenticate messages depending on our response - if message.DigestResponse == "" { - if s.sendEmptyDigestChallenge { - logger.Info( - "msg", "sending empty DigestChallenge response to UserAuthenticate", - ) - return emptyDigestChallengeBytes, nil - } - return nil, service.NewHTTPStatusError( - http.StatusGone, - fmt.Errorf("declining management of user: %s", r.ID), - ) + if s.ua == nil { + return nil, errors.New("no UserAuthenticate handler") } - logger.Debug( - "msg", "sending empty response to second UserAuthenticate", - ) - return nil, nil + return s.ua.UserAuthenticate(r, message) } func (s *Service) SetBootstrapToken(r *mdm.Request, message *mdm.SetBootstrapToken) error { diff --git a/service/nanomdm/ua.go b/service/nanomdm/ua.go new file mode 100644 index 0000000..83a8225 --- /dev/null +++ b/service/nanomdm/ua.go @@ -0,0 +1,78 @@ +package nanomdm + +import ( + "fmt" + "net/http" + + "github.com/micromdm/nanomdm/log" + "github.com/micromdm/nanomdm/log/ctxlog" + "github.com/micromdm/nanomdm/mdm" + "github.com/micromdm/nanomdm/service" + "github.com/micromdm/nanomdm/storage" +) + +// UAService is a basic UserAuthenticate service that optionally implements +// the "zero-length" UserAuthenticate protocol. +// See https://developer.apple.com/documentation/devicemanagement/userauthenticate +type UAService struct { + logger log.Logger + store storage.UserAuthenticateStore + + // By default the UserAuthenticate message will be rejected (410 + // response). If this is set true then a static zero-length + // digest challenge will be supplied to the first UserAuthenticate + // check-in message. See the Discussion section of + // https://developer.apple.com/documentation/devicemanagement/userauthenticate + sendEmptyDigestChallenge bool + storeRejectedUserAuth bool +} + +// NewUAService creates a new UserAuthenticate check-in message handler. +func NewUAService(store storage.UserAuthenticateStore, sendEmptyDigestChallenge bool) *UAService { + return &UAService{ + logger: log.NopLogger, + store: store, + sendEmptyDigestChallenge: sendEmptyDigestChallenge, + } +} + +const emptyDigestChallenge = ` + + + + DigestChallenge + + +` + +var emptyDigestChallengeBytes = []byte(emptyDigestChallenge) + +// UserAuthenticate will decline management of a user unless configured +// for the empty digest 2-step UserAuthenticate protocol. +// It implements the NanoMDM service method for UserAuthenticate check-in messages. +func (s *UAService) UserAuthenticate(r *mdm.Request, message *mdm.UserAuthenticate) ([]byte, error) { + logger := ctxlog.Logger(r.Context, s.logger) + if s.sendEmptyDigestChallenge || s.storeRejectedUserAuth { + if err := s.store.StoreUserAuthenticate(r, message); err != nil { + return nil, err + } + } + // if the DigestResponse is empty then this is the first (of two) + // UserAuthenticate messages depending on our response + if message.DigestResponse == "" { + if s.sendEmptyDigestChallenge { + logger.Info( + "msg", "sending empty DigestChallenge response to UserAuthenticate", + ) + return emptyDigestChallengeBytes, nil + } + return nil, service.NewHTTPStatusError( + http.StatusGone, + fmt.Errorf("declining management of user: %s", r.ID), + ) + } + logger.Debug( + "msg", "sending empty response to second UserAuthenticate", + ) + return nil, nil +} diff --git a/service/nanomdm/ua_test.go b/service/nanomdm/ua_test.go new file mode 100644 index 0000000..f4fc317 --- /dev/null +++ b/service/nanomdm/ua_test.go @@ -0,0 +1,62 @@ +package nanomdm + +import ( + "bytes" + "errors" + "testing" + + "github.com/micromdm/nanomdm/mdm" + "github.com/micromdm/nanomdm/service" +) + +type fauxStore struct { + ua *mdm.UserAuthenticate +} + +func (f *fauxStore) StoreUserAuthenticate(_ *mdm.Request, msg *mdm.UserAuthenticate) error { + f.ua = msg + return nil +} + +func newMDMReq() *mdm.Request { + return &mdm.Request{EnrollID: &mdm.EnrollID{ID: ""}} +} + +func TestUAServiceReject(t *testing.T) { + store := &fauxStore{} + s := NewUAService(store, false) + _, err := s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{}) + var httpErr *service.HTTPStatusError + if !errors.As(err, &httpErr) { + // should be returning a HTTPStatusError (to deny management) + t.Fatalf("no error or incorrect error type") + } + if httpErr.Status != 410 { + // if we've kept the "send-empty" false this needs to return a 410 + // i.e. decline management of the user. + t.Error("status not 410") + } +} + +func TestUAService(t *testing.T) { + store := &fauxStore{} + s := NewUAService(store, true) + ret, err := s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{}) + if err != nil { + // should be no error + t.Fatal(err) + } + if !bytes.Equal(ret, emptyDigestChallengeBytes) { + t.Error("response bytes not equal") + } + // second request with DigestResponse + ret, err = s.UserAuthenticate(newMDMReq(), &mdm.UserAuthenticate{DigestResponse: "test"}) + if err != nil { + // should be no error + t.Fatal(err) + } + if ret != nil { + t.Error("response bytes not empty") + } + +} diff --git a/service/service.go b/service/service.go index 8c57ef9..41f7225 100644 --- a/service/service.go +++ b/service/service.go @@ -11,6 +11,11 @@ type DeclarativeManagement interface { DeclarativeManagement(*mdm.Request, *mdm.DeclarativeManagement) ([]byte, error) } +// UserAuthenticate is an interface for processing the UserAuthenticate MDM check-in message. +type UserAuthenticate interface { + UserAuthenticate(*mdm.Request, *mdm.UserAuthenticate) ([]byte, error) +} + // Checkin represents the various check-in requests. // See https://developer.apple.com/documentation/devicemanagement/check-in type Checkin interface { @@ -19,7 +24,7 @@ type Checkin interface { CheckOut(*mdm.Request, *mdm.CheckOut) error SetBootstrapToken(*mdm.Request, *mdm.SetBootstrapToken) error GetBootstrapToken(*mdm.Request, *mdm.GetBootstrapToken) (*mdm.BootstrapToken, error) - UserAuthenticate(*mdm.Request, *mdm.UserAuthenticate) ([]byte, error) + UserAuthenticate DeclarativeManagement } diff --git a/storage/storage.go b/storage/storage.go index d813cbc..d33293c 100644 --- a/storage/storage.go +++ b/storage/storage.go @@ -9,12 +9,16 @@ import ( "github.com/micromdm/nanomdm/mdm" ) +type UserAuthenticateStore interface { + StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error +} + // CheckinStore stores MDM check-in data. type CheckinStore interface { StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate) error StoreTokenUpdate(r *mdm.Request, msg *mdm.TokenUpdate) error - StoreUserAuthenticate(r *mdm.Request, msg *mdm.UserAuthenticate) error Disable(r *mdm.Request) error + UserAuthenticateStore } // CommandAndReportResultsStore stores and retrieves MDM command queue data. From 83e8601c612be11bdd73be86d35e202b905951e1 Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Tue, 30 Jan 2024 15:45:03 -0800 Subject: [PATCH 2/4] update docs, fix CLI param name --- cmd/nanomdm/main.go | 2 +- docs/operations-guide.md | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/nanomdm/main.go b/cmd/nanomdm/main.go index b9ef665..60fb3ea 100644 --- a/cmd/nanomdm/main.go +++ b/cmd/nanomdm/main.go @@ -71,7 +71,7 @@ func main() { flRetro = flag.Bool("retro", false, "Allow retroactive certificate-authorization association") flDMURLPfx = flag.String("dm", "", "URL to send Declarative Management requests to") flAuthProxy = flag.String("auth-proxy-url", "", "Reverse proxy URL target for MDM-authenticated HTTP requests") - flUAZLChal = flag.Bool("", false, "reply with zero-length DigestChallenge for UserAuthenticate") + flUAZLChal = flag.Bool("ua-zl-dc", false, "reply with zero-length DigestChallenge for UserAuthenticate") ) flag.Parse() diff --git a/docs/operations-guide.md b/docs/operations-guide.md index 95f5f1c..1f1ea3a 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -169,6 +169,12 @@ NanoMDM supports a MicroMDM-compatible [webhook callback](https://github.com/mic Enables the authentication proxy and reverse proxies HTTP requests from the server's `/authproxy/` endpoint to this URL if the client provides the device's enrollment authentication. See below for more information. +### -ua-zl-dc + +* reply with zero-length DigestChallenge for UserAuthenticate + +By default NanoMDM will response to the `UserAuthenticate` message with an HTTP 410 response effectively declining management of the MDM user. Enabling this option turns on the "zero-length" Digest Challenge mode where reply with an empty Digest Challenge to enable management each time a client enrolls. + ## HTTP endpoints & APIs ### MDM From 1e9b5477d83c94d4e3d0d4c52c30808322e52acf Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Tue, 30 Jan 2024 15:47:32 -0800 Subject: [PATCH 3/4] add link in docs --- docs/operations-guide.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/operations-guide.md b/docs/operations-guide.md index 1f1ea3a..985e79b 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -173,7 +173,7 @@ Enables the authentication proxy and reverse proxies HTTP requests from the serv * reply with zero-length DigestChallenge for UserAuthenticate -By default NanoMDM will response to the `UserAuthenticate` message with an HTTP 410 response effectively declining management of the MDM user. Enabling this option turns on the "zero-length" Digest Challenge mode where reply with an empty Digest Challenge to enable management each time a client enrolls. +By default NanoMDM will response to the `UserAuthenticate` message with an HTTP 410 response effectively declining management of the MDM user. Enabling this option turns on the "zero-length" Digest Challenge mode where reply with an empty Digest Challenge to enable management each time a client enrolls. See also [Apple's discussion of UserAthenticate](https://developer.apple.com/documentation/devicemanagement/userauthenticate#discussion) for more information. ## HTTP endpoints & APIs From 859236c935bb3bc7968f03358d77c61bf1b19a65 Mon Sep 17 00:00:00 2001 From: Jesse Peterson Date: Mon, 22 Apr 2024 12:37:46 -0700 Subject: [PATCH 4/4] update docs --- docs/operations-guide.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/operations-guide.md b/docs/operations-guide.md index 985e79b..258c281 100644 --- a/docs/operations-guide.md +++ b/docs/operations-guide.md @@ -173,7 +173,9 @@ Enables the authentication proxy and reverse proxies HTTP requests from the serv * reply with zero-length DigestChallenge for UserAuthenticate -By default NanoMDM will response to the `UserAuthenticate` message with an HTTP 410 response effectively declining management of the MDM user. Enabling this option turns on the "zero-length" Digest Challenge mode where reply with an empty Digest Challenge to enable management each time a client enrolls. See also [Apple's discussion of UserAthenticate](https://developer.apple.com/documentation/devicemanagement/userauthenticate#discussion) for more information. +By default NanoMDM will respond to a `UserAuthenticate` message with an HTTP 410. This effectively declines management of that the user channel for that MDM user. Enabling this option turns on the "zero-length" Digest Challenge mode where NanoMDM replies with an empty Digest Challenge to enable management each time a client enrolls. + +Note that the `UserAuthenticate` message is only for "directory" MDM users and not the "primary" MDM user enrollment. See also [Apple's discussion of UserAthenticate](https://developer.apple.com/documentation/devicemanagement/userauthenticate#discussion) for more information. ## HTTP endpoints & APIs