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

Full support of secret variables in Apple configuration profiles #24925

Merged
merged 9 commits into from
Dec 20, 2024
Merged
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 cmd/fleet/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -1132,6 +1132,7 @@ the way that the Fleet server works.
logger,
mdmCheckinAndCommandService,
ddmService,
commander,
); err != nil {
initFatal(err, "setup mdm apple services")
}
Expand Down
6 changes: 6 additions & 0 deletions cmd/fleetctl/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ func TestApplyTeamSpecs(t *testing.T) {
ds.DeleteMDMAppleDeclarationByNameFunc = func(ctx context.Context, teamID *uint, name string) error {
return nil
}
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

filename := writeTmpYml(t, `
---
Expand Down Expand Up @@ -1359,6 +1362,9 @@ func TestApplyAsGitOps(t *testing.T) {
ds.ListVPPTokensFunc = func(ctx context.Context) ([]*fleet.VPPTokenDB, error) {
return []*fleet.VPPTokenDB{}, nil
}
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

// Apply global config.
name := writeTmpYml(t, `---
Expand Down
11 changes: 11 additions & 0 deletions cmd/fleetctl/gitops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,10 @@ func TestGitOpsFullGlobal(t *testing.T) {
return []*fleet.ABMToken{}, nil
}

ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

const (
fleetServerURL = "https://fleet.example.com"
orgName = "GitOps Test"
Expand Down Expand Up @@ -861,6 +865,10 @@ func TestGitOpsFullTeam(t *testing.T) {
return nil
}

ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

// Queries
query := fleet.Query{}
query.ID = 1
Expand Down Expand Up @@ -2551,6 +2559,9 @@ func setupFullGitOpsPremiumServer(t *testing.T) (*mock.Store, **fleet.AppConfig,
ds.SetSetupExperienceScriptFunc = func(ctx context.Context, script *fleet.Script) error {
return nil
}
ds.ExpandEmbeddedSecretsFunc = func(ctx context.Context, document string) (string, error) {
return document, nil
}

t.Setenv("FLEET_SERVER_URL", fleetServerURL)
t.Setenv("ORG_NAME", orgName)
Expand Down
2 changes: 1 addition & 1 deletion cmd/fleetctl/mdm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ func TestMDMRunCommand(t *testing.T) {
return res, nil
}

enqueuer.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
enqueuer.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
return map[string]error{}, nil
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package tables

import (
"database/sql"
"fmt"

"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)

func init() {
MigrationClient.AddMigration(Up_20241220100000, Down_20241220100000)
}

func Up_20241220100000(tx *sql.Tx) error {
if !columnExists(tx, "nano_commands", "subtype") {
_, err := tx.Exec(fmt.Sprintf(`
ALTER TABLE nano_commands
ADD COLUMN subtype enum('%s','%s') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '%s'`,
mdm.CommandSubtypeNone, mdm.CommandSubtypeProfileWithSecrets, mdm.CommandSubtypeNone))
if err != nil {
return fmt.Errorf("failed to create nano_commands.subtype column: %w", err)
}
}

// With secret variable support, it is possible to have the whole profile as one secret ($FLEET_SECRET_PROFILE),
// which will not be XML when stored. It is cleaner to remove the check than to add a special caveat to documentation.
if constraintExists(tx, "nano_commands", "nano_commands_chk_3") {
_, err := tx.Exec(`ALTER TABLE nano_commands DROP CONSTRAINT nano_commands_chk_3`)
if err != nil {
return fmt.Errorf("failed to drop nano_commands_chk_3 constraint: %w", err)
}
}

return nil
}

func Down_20241220100000(_ *sql.Tx) error {
return nil
}
16 changes: 16 additions & 0 deletions server/datastore/mysql/migrations/tables/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ AND CONSTRAINT_NAME = ?
return count > 0
}

func constraintExists(tx *sql.Tx, table, name string) bool {
var count int
err := tx.QueryRow(`
SELECT COUNT(1)
FROM information_schema.TABLE_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = ?
`, table, name).Scan(&count)
if err != nil {
return false
}

return count > 0
}

func columnExists(tx *sql.Tx, table, column string) bool {
return columnsExists(tx, table, column)
}
Expand Down
8 changes: 4 additions & 4 deletions server/datastore/mysql/schema.sql

Large diffs are not rendered by default.

40 changes: 33 additions & 7 deletions server/mdm/apple/commander.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,18 @@ func NewMDMAppleCommander(mdmStorage fleet.MDMAppleStore, mdmPushService nanomdm
// InstallProfile sends the homonymous MDM command to the given hosts, it also
// takes care of the base64 encoding of the provided profile bytes.
func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []string, profile mobileconfig.Mobileconfig, uuid string) error {
raw, err := svc.SignAndEncodeInstallProfile(ctx, profile, uuid)
if err != nil {
return err
}
err = svc.EnqueueCommand(ctx, hostUUIDs, raw)
return ctxerr.Wrap(ctx, err, "commander install profile")
}

func (svc *MDMAppleCommander) SignAndEncodeInstallProfile(ctx context.Context, profile []byte, commandUUID string) (string, error) {
signedProfile, err := mdmcrypto.Sign(ctx, profile, svc.storage)
if err != nil {
return ctxerr.Wrap(ctx, err, "signing profile")
return "", ctxerr.Wrap(ctx, err, "signing profile")
}

base64Profile := base64.StdEncoding.EncodeToString(signedProfile)
Expand All @@ -66,12 +75,11 @@ func (svc *MDMAppleCommander) InstallProfile(ctx context.Context, hostUUIDs []st
<data>%s</data>
</dict>
</dict>
</plist>`, uuid, base64Profile)
err = svc.EnqueueCommand(ctx, hostUUIDs, raw)
return ctxerr.Wrap(ctx, err, "commander install profile")
</plist>`, commandUUID, base64Profile)
return raw, nil
}

// InstallProfile sends the homonymous MDM command to the given hosts.
// RemoveProfile sends the homonymous MDM command to the given hosts.
func (svc *MDMAppleCommander) RemoveProfile(ctx context.Context, hostUUIDs []string, profileIdentifier string, uuid string) error {
raw := fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
Expand Down Expand Up @@ -366,17 +374,35 @@ func (svc *MDMAppleCommander) EnqueueCommand(ctx context.Context, hostUUIDs []st
return ctxerr.Wrap(ctx, err, "decoding command")
}

if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs, cmd); err != nil {
return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeNone)
}

func (svc *MDMAppleCommander) enqueueAndNotify(ctx context.Context, hostUUIDs []string, cmd *mdm.Command,
subtype mdm.CommandSubtype) error {
if _, err := svc.storage.EnqueueCommand(ctx, hostUUIDs,
&mdm.CommandWithSubtype{Command: *cmd, Subtype: subtype}); err != nil {
return ctxerr.Wrap(ctx, err, "enqueuing command")
}

if err := svc.SendNotifications(ctx, hostUUIDs); err != nil {
return ctxerr.Wrap(ctx, err, "sending notifications")
}

return nil
}

// EnqueueCommandInstallProfileWithSecrets is a special case of EnqueueCommand that does not expand secret variables.
// Secret variables are expanded when the command is sent to the device, and secrets are never stored in the database unencrypted.
func (svc *MDMAppleCommander) EnqueueCommandInstallProfileWithSecrets(ctx context.Context, hostUUIDs []string,
rawCommand mobileconfig.Mobileconfig, commandUUID string) error {
cmd := &mdm.Command{
CommandUUID: commandUUID,
Raw: []byte(rawCommand),
}
cmd.Command.RequestType = "InstallProfile"

return svc.enqueueAndNotify(ctx, hostUUIDs, cmd, mdm.CommandSubtypeProfileWithSecrets)
}

func (svc *MDMAppleCommander) SendNotifications(ctx context.Context, hostUUIDs []string) error {
apnsResponses, err := svc.pusher.Push(ctx, hostUUIDs)
if err != nil {
Expand Down
12 changes: 6 additions & 6 deletions server/mdm/apple/commander_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ func TestMDMAppleCommander(t *testing.T) {
payloadIdentifier := "com-foo-bar"
mc := mobileconfigForTest(payloadName, payloadIdentifier)

mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
require.Equal(t, cmd.Command.RequestType, "InstallProfile")
require.Equal(t, cmd.Command.Command.RequestType, "InstallProfile")
var fullCmd micromdm.CommandPayload
require.NoError(t, plist.Unmarshal(cmd.Raw, &fullCmd))
p7, err := pkcs7.Parse(fullCmd.Command.InstallProfile.Payload)
Expand Down Expand Up @@ -96,9 +96,9 @@ func TestMDMAppleCommander(t *testing.T) {
require.True(t, mdmStorage.RetrievePushInfoFuncInvoked)
mdmStorage.RetrievePushInfoFuncInvoked = false

mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
require.Equal(t, "RemoveProfile", cmd.Command.RequestType)
require.Equal(t, "RemoveProfile", cmd.Command.Command.RequestType)
require.Contains(t, string(cmd.Raw), payloadIdentifier)
return nil, nil
}
Expand All @@ -111,9 +111,9 @@ func TestMDMAppleCommander(t *testing.T) {
require.NoError(t, err)

cmdUUID = uuid.New().String()
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
mdmStorage.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error, error) {
require.NotNil(t, cmd)
require.Equal(t, "InstallEnterpriseApplication", cmd.Command.RequestType)
require.Equal(t, "InstallEnterpriseApplication", cmd.Command.Command.RequestType)
require.Contains(t, string(cmd.Raw), "http://test.example.com")
require.Contains(t, string(cmd.Raw), cmdUUID)
return nil, nil
Expand Down
2 changes: 1 addition & 1 deletion server/mdm/nanomdm/http/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ func RawCommandEnqueueHandler(enqueuer storage.CommandEnqueuer, pusher push.Push
logs := []interface{}{
"msg", "enqueue",
}
idErrs, err := enqueuer.EnqueueCommand(ctx, ids, command)
idErrs, err := enqueuer.EnqueueCommand(ctx, ids, &mdm.CommandWithSubtype{Command: *command, Subtype: mdm.CommandSubtypeNone})
ct := len(ids) - len(idErrs)
if err != nil {
logs = append(logs, "err", err)
Expand Down
12 changes: 12 additions & 0 deletions server/mdm/nanomdm/mdm/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ func DecodeCommandResults(rawResults []byte) (results *CommandResults, err error
return
}

type CommandSubtype string

const (
CommandSubtypeNone CommandSubtype = "None"
CommandSubtypeProfileWithSecrets CommandSubtype = "ProfileWithSecrets"
)

// Command represents a generic MDM command without command-specific fields.
type Command struct {
CommandUUID string
Expand All @@ -52,6 +59,11 @@ type Command struct {
Raw []byte `plist:"-"` // Original command XML plist
}

type CommandWithSubtype struct {
Command
Subtype CommandSubtype
}

// DecodeCommand unmarshals rawCommand into command
func DecodeCommand(rawCommand []byte) (command *Command, err error) {
command = new(Command)
Expand Down
56 changes: 40 additions & 16 deletions server/mdm/nanomdm/service/nanomdm/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ type Service struct {

// GetToken handler
gt service.GetToken

// ProfileService
ps service.ProfileService
}

// normalize generates enrollment IDs that are used by other
Expand Down Expand Up @@ -71,6 +74,12 @@ func WithDeclarativeManagement(dm service.DeclarativeManagement) Option {
}
}

func WithProfileService(ps service.ProfileService) Option {
return func(s *Service) {
s.ps = ps
}
}

// WithUserAuthenticate configures a UserAuthenticate check-in message handler.
func WithUserAuthenticate(ua service.UserAuthenticate) Option {
return func(s *Service) {
Expand Down Expand Up @@ -253,25 +262,40 @@ func (s *Service) CommandAndReportResults(r *mdm.Request, results *mdm.CommandRe
if err != nil {
return nil, fmt.Errorf("retrieving next command: %w", err)
}
if cmd != nil {
if cmd == nil {
logger.Debug(
"msg", "command retrieved",
"command_uuid", cmd.CommandUUID,
"request_type", cmd.Command.RequestType,
"msg", "no command retrieved",
)
// We expand secrets in the command before returning it to the caller so that we never store unencrypted secrets in the database.
expanded, err := s.store.ExpandEmbeddedSecrets(r.Context, string(cmd.Raw))
if err != nil {
// This error is not expected since secrets should have been validated on profile upload.
logger.Info("level", "error", "msg", "expanding embedded secrets", "err", err)
// Since this error should not happen, we use the command as is, without expanding secrets.
} else {
cmd.Raw = []byte(expanded)
}
return cmd, nil
return nil, nil
}
logger.Debug(
"msg", "no command retrieved",
"msg", "command retrieved",
"command_uuid", cmd.CommandUUID,
"request_type", cmd.Command.Command.RequestType,
"subtype", cmd.Subtype,
)
return nil, nil
// We expand secrets in the command before returning it to the caller so that we never store unencrypted secrets in the database.
// User can issue a one-off MDM command that contains a secret variable, for example.
expanded, err := s.store.ExpandEmbeddedSecrets(r.Context, string(cmd.Raw))
if err != nil {
// This error should never happen since secrets have been validated on profile upload.
logger.Info("level", "error", "msg", "expanding embedded secrets", "err", err)
// Since this error should not happen, we simply use the command as is, without expanding secrets.
} else {
cmd.Raw = []byte(expanded)
}
switch cmd.Subtype {
case mdm.CommandSubtypeProfileWithSecrets:
// Secrets were expanded above. Now we need to base64 encode and sign the configuration profile before returning it to the caller.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we looked into encrypting the profile content here? Otherwise, I'm assuming we are ok for this iteration that unencrypted secrets might be accessible in transit or at rest on the device, for example, by a user running profiles in the terminal or via osquery macos_profiles table.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I brought up a similar issue with product.

Are you suggesting there is a way to prevent the device user from viewing profile contents. For example, from viewing the EnrollSecret in our Fleetd configuration profile?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is possible to encrypt profiles. Here are the Apple docs.

processed, err := s.ps.SignAndEncodeInstallProfile(r.Context, cmd.Raw, cmd.CommandUUID)
if err != nil {
logger.Info("level", "error", "msg", "signing and encoding profile", "err", err)
// Since this error should not normally happen, we simply use the command as is. This way the client can fetch the next command and will not be blocked.
return &cmd.Command, nil
}
cmd.Raw = []byte(processed)
return &cmd.Command, nil
default:
return &cmd.Command, nil
}
}
7 changes: 7 additions & 0 deletions server/mdm/nanomdm/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
package service

import (
"context"

"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)

Expand Down Expand Up @@ -46,3 +48,8 @@ type CheckinAndCommandService interface {
Checkin
CommandAndReportResults
}

// ProfileService represents the interface to call specific functions from Fleet's main services.
type ProfileService interface {
SignAndEncodeInstallProfile(ctx context.Context, profile []byte, commandUUID string) (string, error)
}
7 changes: 4 additions & 3 deletions server/mdm/nanomdm/storage/allmulti/queue.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ func (ms *MultiAllStorage) StoreCommandReport(r *mdm.Request, report *mdm.Comman
return err
}

func (ms *MultiAllStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.Command, error) {
func (ms *MultiAllStorage) RetrieveNextCommand(r *mdm.Request, skipNotNow bool) (*mdm.CommandWithSubtype, error) {
val, err := ms.execStores(r.Context, func(s storage.AllStorage) (interface{}, error) {
return s.RetrieveNextCommand(r, skipNotNow)
})
return val.(*mdm.Command), err
return val.(*mdm.CommandWithSubtype), err
}

func (ms *MultiAllStorage) ClearQueue(r *mdm.Request) error {
Expand All @@ -28,7 +28,8 @@ func (ms *MultiAllStorage) ClearQueue(r *mdm.Request) error {
return err
}

func (ms *MultiAllStorage) EnqueueCommand(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
func (ms *MultiAllStorage) EnqueueCommand(ctx context.Context, id []string, cmd *mdm.CommandWithSubtype) (map[string]error,
error) {
val, err := ms.execStores(ctx, func(s storage.AllStorage) (interface{}, error) {
return s.EnqueueCommand(ctx, id, cmd)
})
Expand Down
Loading
Loading