automatically renew macOS identity certificates 30 days prior to their expiration (#17057)

#15332

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

<!-- Note that API documentation changes are now addressed by the
product design team. -->

- [x] Changes file added for user-visible changes in `changes/` or
`orbit/changes/`.
See [Changes
files](https://fleetdm.com/docs/contributing/committing-changes#changes-files)
for more information.
- [x] Added/updated tests
This commit is contained in:
Roberto Dip 2024-02-22 16:23:12 -03:00 committed by GitHub
parent 12f519c853
commit 261332f76c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1172 additions and 35 deletions

1
changes/15332-scep-renew Normal file
View File

@ -0,0 +1 @@
* Automatically renew macOS identity certificates for devices 30 days prior to their expiration.

View File

@ -703,6 +703,7 @@ func newCleanupsAndAggregationSchedule(
logger kitlog.Logger,
enrollHostLimiter fleet.EnrollHostLimiter,
config *config.FleetConfig,
commander *apple_mdm.MDMAppleCommander,
) (*schedule.Schedule, error) {
const (
name = string(fleet.CronCleanupsThenAggregation)
@ -803,6 +804,12 @@ func newCleanupsAndAggregationSchedule(
return verifyDiskEncryptionKeys(ctx, logger, ds, config)
},
),
schedule.WithJob(
"renew_scep_certificates",
func(ctx context.Context) error {
return service.RenewSCEPCertificates(ctx, logger, ds, config, commander)
},
),
schedule.WithJob("query_results_cleanup", func(ctx context.Context) error {
config, err := ds.AppConfig(ctx)
if err != nil {

View File

@ -681,7 +681,11 @@ the way that the Fleet server works.
}()
if err := cronSchedules.StartCronSchedule(func() (fleet.CronSchedule, error) {
return newCleanupsAndAggregationSchedule(ctx, instanceID, ds, logger, redisWrapperDS, &config)
var commander *apple_mdm.MDMAppleCommander
if appCfg.MDM.EnabledAndConfigured {
commander = apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
}
return newCleanupsAndAggregationSchedule(ctx, instanceID, ds, logger, redisWrapperDS, &config, commander)
}); err != nil {
initFatal(err, "failed to register cleanups_then_aggregations schedule")
}

View File

@ -18,6 +18,7 @@ import (
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/cryptoutil"
nanodep_client "github.com/micromdm/nanodep/client"
"github.com/micromdm/nanodep/tokenpki"
"github.com/spf13/cast"
@ -594,6 +595,20 @@ func (m *MDMConfig) AppleAPNs() (cert *tls.Certificate, pemCert, pemKey []byte,
return m.appleAPNs, m.appleAPNsPEMCert, m.appleAPNsPEMKey, nil
}
func (m *MDMConfig) AppleAPNsTopic() (string, error) {
apnsCert, _, _, err := m.AppleAPNs()
if err != nil {
return "", fmt.Errorf("parsing APNs certificates: %w", err)
}
mdmPushCertTopic, err := cryptoutil.TopicFromCert(apnsCert.Leaf)
if err != nil {
return "", fmt.Errorf("extracting topic from APNs certificate: %w", err)
}
return mdmPushCertTopic, nil
}
// AppleSCEP returns the parsed and validated TLS certificate for Apple SCEP.
// It parses and validates it if it hasn't been done yet.
func (m *MDMConfig) AppleSCEP() (cert *tls.Certificate, pemCert, pemKey []byte, err error) {

View File

@ -953,3 +953,80 @@ func (ds *Datastore) MDMDeleteEULA(ctx context.Context, token string) error {
}
return nil
}
func (ds *Datastore) GetHostCertAssociationsToExpire(ctx context.Context, expiryDays, limit int) ([]fleet.SCEPIdentityAssociation, error) {
// TODO(roberto): this is not good because we don't have any indexes on
// h.uuid, due to time constraints, I'm assuming that this
// function is called with a relatively low amount of shas
//
// Note that we use GROUP BY because we can't guarantee unique entries
// based on uuid in the hosts table.
stmt, args, err := sqlx.In(
`SELECT
h.uuid as host_uuid,
ncaa.sha256 as sha256,
COALESCE(MAX(hm.fleet_enroll_ref), '') as enroll_reference
FROM
nano_cert_auth_associations ncaa
LEFT JOIN hosts h ON h.uuid = ncaa.id
LEFT JOIN host_mdm hm ON hm.host_id = h.id
WHERE
cert_not_valid_after BETWEEN '0000-00-00' AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
AND renew_command_uuid IS NULL
GROUP BY
host_uuid, ncaa.sha256, cert_not_valid_after
ORDER BY cert_not_valid_after ASC
LIMIT ?
`, expiryDays, limit)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In query")
}
var uuids []fleet.SCEPIdentityAssociation
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &uuids, stmt, args...); err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, ctxerr.Wrap(ctx, err, "get identity certs close to expiry")
}
return uuids, nil
}
func (ds *Datastore) SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
if len(assocs) == 0 {
return nil
}
var sb strings.Builder
args := make([]any, len(assocs)*3)
for i, assoc := range assocs {
sb.WriteString("(?, ?, ?),")
args[i*3] = assoc.HostUUID
args[i*3+1] = assoc.SHA256
args[i*3+2] = cmdUUID
}
stmt := fmt.Sprintf(`
INSERT INTO nano_cert_auth_associations (id, sha256, renew_command_uuid) VALUES %s
ON DUPLICATE KEY UPDATE
renew_command_uuid = VALUES(renew_command_uuid)
`, strings.TrimSuffix(sb.String(), ","))
return ds.withTx(ctx, func(tx sqlx.ExtContext) error {
res, err := tx.ExecContext(ctx, stmt, args...)
if err != nil {
return fmt.Errorf("failed to update cert associations: %w", err)
}
// NOTE: we can't use insertOnDuplicateDidInsert because the
// LastInsertId check only works tables that have an
// auto-incrementing primary key. See notes in that function
// and insertOnDuplicateDidUpdate to understand the mechanism.
affected, _ := res.RowsAffected()
if affected == 1 {
return errors.New("this function can only be used to update existing associations")
}
return nil
})
}

View File

@ -2,19 +2,25 @@ package mysql
import (
"context"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"sort"
"strconv"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
mdm_types "github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/service/certauth"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/micromdm/nanodep/tokenpki"
"github.com/stretchr/testify/require"
)
@ -35,6 +41,8 @@ func TestMDMShared(t *testing.T) {
{"TestBatchSetProfileLabelAssociations", testBatchSetProfileLabelAssociations},
{"TestBatchSetProfilesTransactionError", testBatchSetMDMProfilesTransactionError},
{"TestMDMEULA", testMDMEULA},
{"TestGetHostCertAssociationsToExpire", testSCEPRenewalHelpers},
{"TestSCEPRenewalHelpers", testSCEPRenewalHelpers},
}
for _, c := range cases {
@ -3130,3 +3138,133 @@ func testMDMEULA(t *testing.T, ds *Datastore) {
err = ds.MDMInsertEULA(ctx, eula)
require.NoError(t, err)
}
func testSCEPRenewalHelpers(t *testing.T, ds *Datastore) {
ctx := context.Background()
testCert, testKey, err := apple_mdm.NewSCEPCACertKey()
require.NoError(t, err)
testCertPEM := tokenpki.PEMCertificate(testCert.Raw)
testKeyPEM := tokenpki.PEMRSAPrivateKey(testKey)
scepDepot, err := ds.NewSCEPDepot(testCertPEM, testKeyPEM)
require.NoError(t, err)
nanoStorage, err := ds.NewMDMAppleMDMStorage(testCertPEM, testKeyPEM)
require.NoError(t, err)
var i int
setHost := func(notAfter time.Time) *fleet.Host {
i++
h, err := ds.NewHost(ctx, &fleet.Host{
Hostname: fmt.Sprintf("test-host%d-name", i),
OsqueryHostID: ptr.String(fmt.Sprintf("osquery-%d", i)),
NodeKey: ptr.String(fmt.Sprintf("nodekey-%d", i)),
UUID: fmt.Sprintf("test-uuid-%d", i),
Platform: "darwin",
})
require.NoError(t, err)
// create a cert + association
serial, err := scepDepot.Serial()
require.NoError(t, err)
cert := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: "FleetDM Identity",
},
NotAfter: notAfter,
// use the host UUID, just to make sure they're
// different from each other, we don't care about the
// DER contents here
Raw: []byte(h.UUID)}
err = scepDepot.Put(cert.Subject.CommonName, cert)
require.NoError(t, err)
req := mdm.Request{
EnrollID: &mdm.EnrollID{ID: h.UUID},
Context: ctx,
}
certHash := certauth.HashCert(cert)
err = nanoStorage.AssociateCertHash(&req, certHash, notAfter)
require.NoError(t, err)
nanoEnroll(t, ds, h, false)
return h
}
// certs expired at lest 1 year ago
h1 := setHost(time.Now().AddDate(-1, -1, 0))
h2 := setHost(time.Now().AddDate(-1, 0, 0))
// cert that expires in 1 month
h3 := setHost(time.Now().AddDate(0, 1, 0))
// cert that expires in 1 year
h4 := setHost(time.Now().AddDate(1, 0, 0))
// list assocs that expire in the next 10 days
assocs, err := ds.GetHostCertAssociationsToExpire(ctx, 10, 100)
require.NoError(t, err)
require.Len(t, assocs, 2)
require.Equal(t, h1.UUID, assocs[0].HostUUID)
require.Equal(t, h2.UUID, assocs[1].HostUUID)
// list certs that expire in the next 1000 days with limit = 1
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 1000, 1)
require.NoError(t, err)
require.Len(t, assocs, 1)
require.Equal(t, h1.UUID, assocs[0].HostUUID)
// list certs that expire in the next 50 days
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 50, 100)
require.NoError(t, err)
require.Len(t, assocs, 3)
require.Equal(t, h1.UUID, assocs[0].HostUUID)
require.Equal(t, h2.UUID, assocs[1].HostUUID)
require.Equal(t, h3.UUID, assocs[2].HostUUID)
// list certs that expire in the next 1000 days
assocs, err = ds.GetHostCertAssociationsToExpire(ctx, 1000, 100)
require.NoError(t, err)
require.Len(t, assocs, 4)
require.Equal(t, h1.UUID, assocs[0].HostUUID)
require.Equal(t, h2.UUID, assocs[1].HostUUID)
require.Equal(t, h3.UUID, assocs[2].HostUUID)
require.Equal(t, h4.UUID, assocs[3].HostUUID)
checkSCEPRenew := func(assoc fleet.SCEPIdentityAssociation, want *string) {
var got *string
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.GetContext(ctx, q, &got, `SELECT renew_command_uuid FROM nano_cert_auth_associations WHERE id = ?`, assoc.HostUUID)
})
require.EqualValues(t, want, got)
}
// insert dummy nano commands
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err = q.ExecContext(ctx, `
INSERT INTO nano_commands (command_uuid, request_type, command)
VALUES ('foo', 'foo', '<?xml'), ('bar', 'bar', '<?xml')
`)
return err
})
err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{}, "foo")
checkSCEPRenew(assocs[0], nil)
checkSCEPRenew(assocs[1], nil)
checkSCEPRenew(assocs[2], nil)
checkSCEPRenew(assocs[3], nil)
require.NoError(t, err)
err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{assocs[0]}, "foo")
require.NoError(t, err)
checkSCEPRenew(assocs[0], ptr.String("foo"))
checkSCEPRenew(assocs[1], nil)
checkSCEPRenew(assocs[2], nil)
checkSCEPRenew(assocs[3], nil)
err = ds.SetCommandForPendingSCEPRenewal(ctx, assocs, "bar")
require.NoError(t, err)
checkSCEPRenew(assocs[0], ptr.String("bar"))
checkSCEPRenew(assocs[1], ptr.String("bar"))
checkSCEPRenew(assocs[2], ptr.String("bar"))
checkSCEPRenew(assocs[3], ptr.String("bar"))
err = ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{{HostUUID: "foo", SHA256: "bar"}}, "bar")
require.ErrorContains(t, err, "this function can only be used to update existing associations")
}

View File

@ -0,0 +1,143 @@
package tables
import (
"crypto/sha256"
"crypto/x509"
"database/sql"
"encoding/hex"
"encoding/pem"
"fmt"
"log"
"strings"
"time"
"github.com/jmoiron/sqlx"
"github.com/jmoiron/sqlx/reflectx"
)
func init() {
MigrationClient.AddMigration(Up_20240222073518, Down_20240222073518)
}
func Up_20240222073518(tx *sql.Tx) error {
_, err := tx.Exec(`
ALTER TABLE nano_cert_auth_associations
-- used to detect identity certificates that are about to expire. While we have
-- access to the scep_certificates table, nanomdm assumes that you can use any CA
-- to issue your identity certificates, as such we can't add a foreign key here
-- without major changes.
ADD COLUMN cert_not_valid_after TIMESTAMP NULL,
-- used to track the command issued to renew the identity certificate (if one)
ADD COLUMN renew_command_uuid VARCHAR(127) COLLATE utf8mb4_unicode_ci NULL,
ADD CONSTRAINT renew_command_uuid_fk
FOREIGN KEY (renew_command_uuid) REFERENCES nano_commands (command_uuid)
`)
if err != nil {
return fmt.Errorf("failed to alter nano_cert_auth_associations table: %w", err)
}
if err := batchUpdateCertAssociationsTimestamps(tx); err != nil {
return fmt.Errorf("failed to update associations timestamps: %w", err)
}
return nil
}
func batchUpdateCertAssociationsTimestamps(tx *sql.Tx) error {
txx := sqlx.Tx{Tx: tx, Mapper: reflectx.NewMapperFunc("db", sqlx.NameMapper)}
var totalCount int
if err := txx.Get(&totalCount, "SELECT COUNT(*) FROM scep_certificates"); err != nil {
return fmt.Errorf("failed to get total count of scep_certificates: %w", err)
}
const batchSize = 100
for offset := 0; offset < totalCount; offset += batchSize {
if err := updateCertAssociationTimestamps(&txx, batchSize, offset); err != nil {
return fmt.Errorf("updating batch with offset %d: %w", offset, err)
}
}
return nil
}
func updateCertAssociationTimestamps(txx *sqlx.Tx, limit, offset int) error {
var scepCerts []struct {
Serial string `db:"serial"`
CertificatePEM []byte `db:"certificate_pem"`
}
if err := txx.Select(
&scepCerts, `
SELECT certificate_pem
FROM scep_certificates
ORDER BY serial
LIMIT ? OFFSET ?
`,
limit, offset,
); err != nil {
return fmt.Errorf("failed to retrieve scep_certificates: %w", err)
}
shas := make([]string, len(scepCerts))
expiries := make(map[string]time.Time, len(scepCerts))
for i, rawCert := range scepCerts {
block, _ := pem.Decode(rawCert.CertificatePEM)
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
log.Printf("failed to parse certificate with serial %s", rawCert.Serial)
continue
}
hashed := hashCert(cert)
shas[i] = hashed
expiries[hashed] = cert.NotAfter
}
var assocs []struct {
HostUUID string `db:"id"`
SHA256 string `db:"sha256"`
CreatedAt time.Time `db:"created_at"`
}
selectAssocStmt, selectAssocArgs, err := sqlx.In(`
SELECT id, sha256
FROM nano_cert_auth_associations
WHERE sha256 IN (?)
`, shas)
if err != nil {
return fmt.Errorf("building sqlx.In for cert associations: %w", err)
}
if err := txx.Select(&assocs, selectAssocStmt, selectAssocArgs...); err != nil {
return fmt.Errorf("failed to retrieve cert associations: %w", err)
}
var sb strings.Builder
updateAssocArgs := make([]any, len(assocs)*3)
for i, assoc := range assocs {
sb.WriteString("(?, ?, ?),")
updateAssocArgs[i*3] = assoc.HostUUID
updateAssocArgs[i*3+1] = assoc.SHA256
updateAssocArgs[i*3+2] = expiries[assoc.SHA256]
}
updateAssocStmt := fmt.Sprintf(`
INSERT INTO nano_cert_auth_associations (id, sha256, cert_not_valid_after) VALUES %s
ON DUPLICATE KEY UPDATE
cert_not_valid_after = VALUES(cert_not_valid_after),
updated_at = updated_at
`, strings.TrimSuffix(sb.String(), ","))
if _, err := txx.Exec(updateAssocStmt, updateAssocArgs...); err != nil {
return fmt.Errorf("failed to update cert associations: %w", err)
}
return nil
}
func hashCert(cert *x509.Certificate) string {
hashed := sha256.Sum256(cert.Raw)
return hex.EncodeToString(hashed[:])
}
func Down_20240222073518(tx *sql.Tx) error {
return nil
}

View File

@ -0,0 +1,137 @@
package tables
import (
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
func TestUp_20240222073518(t *testing.T) {
db := applyUpToPrev(t)
_, err := db.Exec("INSERT INTO scep_serials (serial) VALUES (1), (2)")
require.NoError(t, err)
threeDaysAgo := time.Now().UTC().Add(-72 * time.Hour).Truncate(time.Second)
_, err = db.Exec(`
INSERT INTO scep_certificates (serial, not_valid_before, not_valid_after, certificate_pem)
VALUES (?, ?, ?, ?), (?, ?, ?, ?)`,
// not_valid_* values don't really matter as the migration
// takes the value from the parsed cert.
1, threeDaysAgo, threeDaysAgo, dummyCert1,
2, threeDaysAgo, threeDaysAgo, dummyCert2,
)
require.NoError(t, err)
sha1, sha2 := "4c51d40f56f5c5e13448995d4d2fd0b6b7befef860e4e7341c355ab38031ee35", "53c2dc9ce116a1df4adfba0c556843625fd1e91f83fc89a47c3267dff9a4c4ba" // #nosec G101
_, err = db.Exec(`
INSERT INTO nano_cert_auth_associations (id, sha256, created_at, updated_at)
VALUES (?, ?, ?, ?), (?, ?, ?, ?), (?, ?, ?, ?)`,
"uuid-1", sha1, threeDaysAgo, threeDaysAgo,
"uuid-2", sha2, threeDaysAgo, threeDaysAgo,
// host with duplicate cert, should never happen, but we don't
// have constraints in the db.
"uuid-3", sha2, threeDaysAgo, threeDaysAgo,
)
require.NoError(t, err)
applyNext(t, db)
var assoc struct {
HostUUID string `db:"id"`
SHA256 string `db:"sha256"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
CertNotValidAfter *time.Time `db:"cert_not_valid_after"`
RenewCommandUUID *string `db:"renew_command_uuid"`
}
selectStmt := "SELECT id, sha256, created_at, updated_at, cert_not_valid_after, renew_command_uuid FROM nano_cert_auth_associations WHERE id = ?"
// new values are filled and timestamps preserved
err = sqlx.Get(db, &assoc, selectStmt, "uuid-1")
require.NoError(t, err)
require.Equal(t, "uuid-1", assoc.HostUUID)
require.Equal(t, sha1, assoc.SHA256)
require.Equal(t, threeDaysAgo, assoc.CreatedAt)
require.Equal(t, threeDaysAgo, assoc.UpdatedAt)
require.Equal(t, "2025-02-20 19:57:24", (*assoc.CertNotValidAfter).Format("2006-01-02 15:04:05"))
require.Nil(t, assoc.RenewCommandUUID)
err = sqlx.Get(db, &assoc, selectStmt, "uuid-2")
require.NoError(t, err)
require.Equal(t, "uuid-2", assoc.HostUUID)
require.Equal(t, sha2, assoc.SHA256)
require.Equal(t, threeDaysAgo, assoc.CreatedAt)
require.Equal(t, threeDaysAgo, assoc.UpdatedAt)
require.Equal(t, "2025-02-20 19:57:25", (*assoc.CertNotValidAfter).Format("2006-01-02 15:04:05"))
require.Nil(t, assoc.RenewCommandUUID)
err = sqlx.Get(db, &assoc, selectStmt, "uuid-3")
require.NoError(t, err)
require.Equal(t, "uuid-3", assoc.HostUUID)
require.Equal(t, sha2, assoc.SHA256)
require.Equal(t, threeDaysAgo, assoc.CreatedAt)
require.Equal(t, threeDaysAgo, assoc.UpdatedAt)
require.Equal(t, "2025-02-20 19:57:25", (*assoc.CertNotValidAfter).Format("2006-01-02 15:04:05"))
require.Nil(t, assoc.RenewCommandUUID)
// creating a new association sets NULL as default values
_, err = db.Exec(`
INSERT INTO nano_cert_auth_associations (id, sha256)
VALUES (?, ?)`, "uuid-4", sha1)
require.NoError(t, err)
err = sqlx.Get(db, &assoc, selectStmt, "uuid-4")
require.NoError(t, err)
require.Equal(t, "uuid-4", assoc.HostUUID)
require.Equal(t, sha1, assoc.SHA256)
require.Nil(t, assoc.CertNotValidAfter)
require.Nil(t, assoc.RenewCommandUUID)
}
var dummyCert1 = []byte(`-----BEGIN CERTIFICATE-----
MIIDgDCCAmigAwIBAgIBAjANBgkqhkiG9w0BAQsFADBBMQkwBwYDVQQGEwAxEDAO
BgNVBAoTB3NjZXAtY2ExEDAOBgNVBAsTB1NDRVAgQ0ExEDAOBgNVBAMTB0ZsZWV0
RE0wHhcNMjQwMjIxMTk0NzI0WhcNMjUwMjIwMTk1NzI0WjBdMRswGQYDVQQKExJm
bGVldC1vcmdhbml6YXRpb24xPjA8BgNVBAMTNWZsZWV0LXRlc3RkZXZpY2UtN0U0
RUJENTQtNjVDNy00RkU2LThFODUtOUUyNDEwMTQ1RENEMIIBIjANBgkqhkiG9w0B
AQEFAAOCAQ8AMIIBCgKCAQEAtgu75XAA5B2iys8DIZwdf2pdzFk157vyZZTnLI4r
7whAtLV556c6hjstyXhOmkut+kfiWHWoKQgbrtBj5LTfXwDbu11FapJvYPiI/GwD
vAQ+KbV9JcoGX70vL5Qmh+M2P+Ky//cE/zDc2YvPpEk4lcR+BNMJ1SnpRqZQ7ggC
0mw62TWbnOuQM4o+1ykvDpJBJhrLxdsEVNaGZVRb0W/GRLzMZNbkQtcBxhpi0yqy
iAScF75A0uy7pRSg1Fkr612qqA2bUcPMY901t264Hn7/YyAorVQS7iEvX9DVQbVu
T4GNtU5VaDrFsWBlDjVyj2+KUUU2g4klYJLEbfIjSa62WQIDAQABo2cwZTAOBgNV
HQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFK9+cymm
EOOnA6EYicjk/OrJQI74MB8GA1UdIwQYMBaAFGmjhvWYNxfl4+HKLj8gWPCbXnqc
MA0GCSqGSIb3DQEBCwUAA4IBAQClD0xOhWS7Pqmiz6t0cC91sL2nAHgFtFhSQKNY
bQFGb0GIJQe0YVV1fJbDDqgHdaYXz+QwJWKfCui0ixYEPgho4SqdeNWsRgDs5EqU
chV6P/+yksXdKiu5f2wmf1T3oqgnrBxTm9bXe2ZQFR77FeeeA1AHUAOCESI3d6QF
ClvMWXXA/cutC3Wp/34M540trLGiM914whaf0Pb6Rx8HjldEn/dThWOKZDYK4MSK
4W2h3vw2aouSe46i86VtYTaDfTP5H4As+N6NunT7lK6sc3UWeWo7k3dliiywnQ4Y
AiCWE3wJMLpNwPaxdxz/grg8MLw9uPvfznZ9K9G/i5IDvI9O
-----END CERTIFICATE-----`)
var dummyCert2 = []byte(`-----BEGIN CERTIFICATE-----
MIIDgDCCAmigAwIBAgIBAzANBgkqhkiG9w0BAQsFADBBMQkwBwYDVQQGEwAxEDAO
BgNVBAoTB3NjZXAtY2ExEDAOBgNVBAsTB1NDRVAgQ0ExEDAOBgNVBAMTB0ZsZWV0
RE0wHhcNMjQwMjIxMTk0NzI1WhcNMjUwMjIwMTk1NzI1WjBdMRswGQYDVQQKExJm
bGVldC1vcmdhbml6YXRpb24xPjA8BgNVBAMTNWZsZWV0LXRlc3RkZXZpY2UtMjQw
RkI4NEQtQzFBOS00ODhCLUEzNDItQkFBOTI2NTkwOEJBMIIBIjANBgkqhkiG9w0B
AQEFAAOCAQ8AMIIBCgKCAQEA0H/BTmCHrLrYHn0CWC+V0qMVvvOjE9fE178DOU8W
x/W5FGw9Vm+kYE2Tt/dQVLDYUnEg8u1v6JCN2YErGc3eLjyUPVz28778sVQCTc7s
Ax1QTxoRjxss7KDhSArdPyEu2YzbKfefEcVqPymDxQTeTKrscgN9XTIe6uvb6qCM
3HHKQJsUb8me8Sat8RyR1q+ahR7vrj9pHCXC/nyeK2l1xmnTgz2++C47zMVzjJ7g
VduG3SV440spcd/0TbCjYvu2qe4KcK1TypAbjyo/XOBI75ZV/S8uLmFR9C1XxDvQ
1rngNjyHa24LiweOYd3MIVe+g8htsOCOB8S9hWhN8Xn1OwIDAQABo2cwZTAOBgNV
HQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwHQYDVR0OBBYEFL3wSLk7
LWXNnzzNM4ZrIhPEL/0OMB8GA1UdIwQYMBaAFGmjhvWYNxfl4+HKLj8gWPCbXnqc
MA0GCSqGSIb3DQEBCwUAA4IBAQBXEbOh4hCbOfnRbtBUtd5s1aNd0N+E11eFJM6k
hwgOzHCgGrfG7eh/8QQ+4fYAnpyBEEz863EEqfmPY++MifLI7AI8b82EqxNVT8UK
YeFIvbtOwgKiq+YDLIzXPzRzOS6lgGB68nFNRyni4TeTCx5aaKBKfWlDNwOCdI7c
F97od8YqLp1wDG5caCKVvzLXbOvMZmdmjztKZoI+/SjPDpVsNKZrixYmijDVhZNf
Hd2ktxwNgxBx6TDAbCjwXhim2vPAg7ZoklxLHN4KS2F+ZtKDUbdR2WZyolxJh5QC
KuY7qFtlQZQFIcXnSpgXTC6tpG+oldTkz9exA4Zm5eXqTBfU
-----END CERTIFICATE-----`)

File diff suppressed because one or more lines are too long

View File

@ -518,3 +518,20 @@ type ProfileMatcher interface {
PreassignProfile(ctx context.Context, payload MDMApplePreassignProfilePayload) error
RetrieveProfiles(ctx context.Context, externalHostIdentifier string) (MDMApplePreassignHostProfiles, error)
}
// SCEPIdentityCertificate represents a certificate issued during MDM
// enrollment.
type SCEPIdentityCertificate struct {
Serial string `db:"serial"`
NotValidAfter time.Time `db:"not_valid_after"`
CertificatePEM []byte `db:"certificate_pem"`
}
// SCEPIdentityAssociation represents an association between an identity
// certificate an a specific host.
type SCEPIdentityAssociation struct {
HostUUID string `db:"host_uuid"`
SHA256 string `db:"sha256"`
EnrollReference string `db:"enroll_reference"`
RenewCommandUUID string `db:"renew_command_uuid"`
}

View File

@ -777,6 +777,14 @@ type Datastore interface {
SetDiskEncryptionResetStatus(ctx context.Context, hostID uint, status bool) error
// GetHostCertAssociationsToExpire retrieves host certificate
// associations that are close to expire and don't have a renewal in
// progress based on the provided arguments.
GetHostCertAssociationsToExpire(ctx context.Context, expiryDays, limit int) ([]SCEPIdentityAssociation, error)
// SetCommandForPendingSCEPRenewal tracks the command used to renew a scep certificate
SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []SCEPIdentityAssociation, cmdUUID string) error
// UpdateVerificationHostMacOSProfiles updates status of macOS profiles installed on a given
// host. The toVerify, toFail, and toRetry slices contain the identifiers of the profiles that
// should be verified, failed, and retried, respectively. For each profile in the toRetry slice,

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"encoding/xml"
"fmt"
"net/url"
"strings"
"text/template"
"time"
@ -14,6 +15,7 @@ import (
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/logging"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/log/level"
@ -741,6 +743,21 @@ func GenerateEnrollmentProfileMobileconfig(orgName, fleetURL, scepChallenge, top
return buf.Bytes(), nil
}
func AddEnrollmentRefToFleetURL(fleetURL, reference string) (string, error) {
if reference == "" {
return fleetURL, nil
}
u, err := url.Parse(fleetURL)
if err != nil {
return "", fmt.Errorf("parsing configured server URL: %w", err)
}
q := u.Query()
q.Add(mobileconfig.FleetEnrollReferenceKey, reference)
u.RawQuery = q.Encode()
return u.String(), nil
}
// ProfileBimap implements bidirectional mapping for profiles, and utility
// functions to generate those mappings based on frequently used operations.
type ProfileBimap struct {

View File

@ -10,6 +10,7 @@ import (
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mock"
nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep"
"github.com/go-kit/log"
@ -133,6 +134,54 @@ func TestDEPService(t *testing.T) {
})
}
func TestAddEnrollmentRefToFleetURL(t *testing.T) {
const (
baseFleetURL = "https://example.com"
reference = "enroll-ref"
)
tests := []struct {
name string
fleetURL string
reference string
expectedOutput string
expectError bool
}{
{
name: "empty Reference",
fleetURL: baseFleetURL,
reference: "",
expectedOutput: baseFleetURL,
expectError: false,
},
{
name: "valid URL and Reference",
fleetURL: baseFleetURL,
reference: reference,
expectedOutput: baseFleetURL + "?" + mobileconfig.FleetEnrollReferenceKey + "=" + reference,
expectError: false,
},
{
name: "invalid URL",
fleetURL: "://invalid-url",
reference: reference,
expectError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
output, err := AddEnrollmentRefToFleetURL(tc.fleetURL, tc.reference)
if tc.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.expectedOutput, output)
}
})
}
}
type notFoundError struct{}
func (e notFoundError) IsNotFound() bool { return true }

View File

@ -97,7 +97,7 @@ func New(next service.CheckinAndCommandService, storage storage.CertAuthStore, o
return certAuth
}
func hashCert(cert *x509.Certificate) string {
func HashCert(cert *x509.Certificate) string {
hashed := sha256.Sum256(cert.Raw)
b := make([]byte, len(hashed))
copy(b, hashed[:])
@ -112,7 +112,7 @@ func (s *CertAuth) associateNewEnrollment(r *mdm.Request) error {
return err
}
logger := ctxlog.Logger(r.Context, s.logger)
hash := hashCert(r.Certificate)
hash := HashCert(r.Certificate)
if hasHash, err := s.storage.HasCertHash(r, hash); err != nil {
return err
} else if hasHash {
@ -137,7 +137,7 @@ func (s *CertAuth) associateNewEnrollment(r *mdm.Request) error {
}
}
}
if err := s.storage.AssociateCertHash(r, hash); err != nil {
if err := s.storage.AssociateCertHash(r, hash, r.Certificate.NotAfter); err != nil {
return err
}
logger.Info(
@ -157,7 +157,7 @@ func (s *CertAuth) validateAssociateExistingEnrollment(r *mdm.Request) error {
return err
}
logger := ctxlog.Logger(r.Context, s.logger)
hash := hashCert(r.Certificate)
hash := HashCert(r.Certificate)
if isAssoc, err := s.storage.IsCertHashAssociated(r, hash); err != nil {
return err
} else if isAssoc {
@ -211,7 +211,7 @@ func (s *CertAuth) validateAssociateExistingEnrollment(r *mdm.Request) error {
if s.warnOnly {
return nil
}
if err := s.storage.AssociateCertHash(r, hash); err != nil {
if err := s.storage.AssociateCertHash(r, hash, r.Certificate.NotAfter); err != nil {
return err
}
logger.Info(

View File

@ -1,6 +1,8 @@
package allmulti
import (
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/storage"
)
@ -26,9 +28,9 @@ func (ms *MultiAllStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bo
return val.(bool), err
}
func (ms *MultiAllStorage) AssociateCertHash(r *mdm.Request, hash string) error {
func (ms *MultiAllStorage) AssociateCertHash(r *mdm.Request, hash string, certNotValidAfter time.Time) error {
_, err := ms.execStores(r.Context, func(s storage.AllStorage) (interface{}, error) {
return nil, s.AssociateCertHash(r, hash)
return nil, s.AssociateCertHash(r, hash, certNotValidAfter)
})
return err
}

View File

@ -6,6 +6,7 @@ import (
"os"
"path"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)
@ -52,7 +53,7 @@ func (s *FileStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool, e
return strings.ToLower(string(b)) == strings.ToLower(hash), nil
}
func (s *FileStorage) AssociateCertHash(r *mdm.Request, hash string) error {
func (s *FileStorage) AssociateCertHash(r *mdm.Request, hash string, _ time.Time) error {
f, err := os.OpenFile(
path.Join(s.path, CertAuthAssociationsFilename),
os.O_APPEND|os.O_CREATE|os.O_WRONLY,

View File

@ -3,6 +3,7 @@ package mysql
import (
"context"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)
@ -38,14 +39,18 @@ func (s *MySQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool,
)
}
func (s *MySQLStorage) AssociateCertHash(r *mdm.Request, hash string) error {
func (s *MySQLStorage) AssociateCertHash(r *mdm.Request, hash string, certNotValidAfter time.Time) error {
_, err := s.db.ExecContext(
r.Context, `
INSERT INTO nano_cert_auth_associations (id, sha256) VALUES (?, ?)
INSERT INTO nano_cert_auth_associations (id, sha256, cert_not_valid_after) VALUES (?, ?, ?)
ON DUPLICATE KEY
UPDATE sha256 = VALUES(sha256);`,
UPDATE
sha256 = VALUES(sha256),
cert_not_valid_after = VALUES(cert_not_valid_after),
renew_command_uuid = NULL;`,
r.ID,
strings.ToLower(hash),
certNotValidAfter,
)
return err
}

View File

@ -3,6 +3,7 @@ package pgsql
import (
"context"
"strings"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)
@ -39,7 +40,7 @@ func (s *PgSQLStorage) IsCertHashAssociated(r *mdm.Request, hash string) (bool,
}
// AssociateCertHash "DO NOTHING" on duplicated keys
func (s *PgSQLStorage) AssociateCertHash(r *mdm.Request, hash string) error {
func (s *PgSQLStorage) AssociateCertHash(r *mdm.Request, hash string, _ time.Time) error {
_, err := s.db.ExecContext(
r.Context, `
INSERT INTO cert_auth_associations (id, sha256)

View File

@ -5,6 +5,7 @@ package storage
import (
"context"
"crypto/tls"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
)
@ -62,7 +63,7 @@ type CertAuthStore interface {
HasCertHash(r *mdm.Request, hash string) (bool, error)
EnrollmentHasCertHash(r *mdm.Request, hash string) (bool, error)
IsCertHashAssociated(r *mdm.Request, hash string) (bool, error)
AssociateCertHash(r *mdm.Request, hash string) error
AssociateCertHash(r *mdm.Request, hash string, certNotValidAfter time.Time) error
}
// StoreMigrator retrieves MDM check-ins

View File

@ -6,6 +6,7 @@ import (
"context"
"crypto/tls"
"sync"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
@ -47,7 +48,7 @@ type EnrollmentHasCertHashFunc func(r *mdm.Request, hash string) (bool, error)
type IsCertHashAssociatedFunc func(r *mdm.Request, hash string) (bool, error)
type AssociateCertHashFunc func(r *mdm.Request, hash string) error
type AssociateCertHashFunc func(r *mdm.Request, hash string, certNotValidAfter time.Time) error
type RetrieveMigrationCheckinsFunc func(p0 context.Context, p1 chan<- interface{}) error
@ -241,11 +242,11 @@ func (fs *MDMAppleStore) IsCertHashAssociated(r *mdm.Request, hash string) (bool
return fs.IsCertHashAssociatedFunc(r, hash)
}
func (fs *MDMAppleStore) AssociateCertHash(r *mdm.Request, hash string) error {
func (fs *MDMAppleStore) AssociateCertHash(r *mdm.Request, hash string, certNotValidAfter time.Time) error {
fs.mu.Lock()
fs.AssociateCertHashFuncInvoked = true
fs.mu.Unlock()
return fs.AssociateCertHashFunc(r, hash)
return fs.AssociateCertHashFunc(r, hash, certNotValidAfter)
}
func (fs *MDMAppleStore) RetrieveMigrationCheckins(p0 context.Context, p1 chan<- interface{}) error {

View File

@ -544,6 +544,10 @@ type GetHostDiskEncryptionKeyFunc func(ctx context.Context, hostID uint) (*fleet
type SetDiskEncryptionResetStatusFunc func(ctx context.Context, hostID uint, status bool) error
type GetHostCertAssociationsToExpireFunc func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error)
type SetCommandForPendingSCEPRenewalFunc func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error
type UpdateHostMDMProfilesVerificationFunc func(ctx context.Context, host *fleet.Host, toVerify []string, toFail []string, toRetry []string) error
type GetHostMDMProfilesExpectedForVerificationFunc func(ctx context.Context, host *fleet.Host) (map[string]*fleet.ExpectedMDMProfile, error)
@ -1610,6 +1614,12 @@ type DataStore struct {
SetDiskEncryptionResetStatusFunc SetDiskEncryptionResetStatusFunc
SetDiskEncryptionResetStatusFuncInvoked bool
GetHostCertAssociationsToExpireFunc GetHostCertAssociationsToExpireFunc
GetHostCertAssociationsToExpireFuncInvoked bool
SetCommandForPendingSCEPRenewalFunc SetCommandForPendingSCEPRenewalFunc
SetCommandForPendingSCEPRenewalFuncInvoked bool
UpdateHostMDMProfilesVerificationFunc UpdateHostMDMProfilesVerificationFunc
UpdateHostMDMProfilesVerificationFuncInvoked bool
@ -3868,6 +3878,20 @@ func (s *DataStore) SetDiskEncryptionResetStatus(ctx context.Context, hostID uin
return s.SetDiskEncryptionResetStatusFunc(ctx, hostID, status)
}
func (s *DataStore) GetHostCertAssociationsToExpire(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
s.mu.Lock()
s.GetHostCertAssociationsToExpireFuncInvoked = true
s.mu.Unlock()
return s.GetHostCertAssociationsToExpireFunc(ctx, expiryDays, limit)
}
func (s *DataStore) SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
s.mu.Lock()
s.SetCommandForPendingSCEPRenewalFuncInvoked = true
s.mu.Unlock()
return s.SetCommandForPendingSCEPRenewalFunc(ctx, assocs, cmdUUID)
}
func (s *DataStore) UpdateHostMDMProfilesVerification(ctx context.Context, host *fleet.Host, toVerify []string, toFail []string, toRetry []string) error {
s.mu.Lock()
s.UpdateHostMDMProfilesVerificationFuncInvoked = true

View File

@ -9,7 +9,6 @@ import (
"io"
"mime/multipart"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
@ -20,6 +19,7 @@ import (
"github.com/fleetdm/fleet/v4/pkg/optjson"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/contexts/license"
"github.com/fleetdm/fleet/v4/server/contexts/logging"
@ -32,8 +32,8 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/sso"
"github.com/fleetdm/fleet/v4/server/worker"
kitlog "github.com/go-kit/kit/log"
"github.com/go-kit/kit/log/level"
kitlog "github.com/go-kit/log"
"github.com/go-kit/log/level"
"github.com/google/uuid"
"github.com/groob/plist"
"github.com/micromdm/nanodep/godep"
@ -1087,16 +1087,9 @@ func (svc *Service) GetMDMAppleEnrollmentProfileByToken(ctx context.Context, tok
return nil, ctxerr.Wrap(ctx, err)
}
enrollURL := appConfig.ServerSettings.ServerURL
if ref != "" {
u, err := url.Parse(enrollURL)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "parsing configured server URL")
}
q := u.Query()
q.Add(mobileconfig.FleetEnrollReferenceKey, ref)
u.RawQuery = q.Encode()
enrollURL = u.String()
enrollURL, err := apple_mdm.AddEnrollmentRefToFleetURL(appConfig.ServerSettings.ServerURL, ref)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "adding reference to fleet URL")
}
mobileconfig, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
@ -2824,3 +2817,123 @@ func (svc *Service) getConfigAppleBMDefaultTeamID(ctx context.Context, appCfg *f
return tmID, nil
}
// scepCertRenewalThresholdDays defines the number of days before a SCEP
// certificate must be renewed.
const scepCertRenewalThresholdDays = 30
// maxCertsRenewalPerRun specifies the maximum number of certificates to renew
// in a single cron run.
//
// Assuming that the cron runs every hour, we'll enqueue 24,000 renewals per
// day, and we have room for 24,000 * scepCertRenewalThresholdDays total
// renewals.
//
// For a default of 30 days as a threshold this gives us room for a fleet of
// 720,000 devices expiring at the same time.
const maxCertsRenewalPerRun = 100
func RenewSCEPCertificates(
ctx context.Context,
logger kitlog.Logger,
ds fleet.Datastore,
config *config.FleetConfig,
commander *apple_mdm.MDMAppleCommander,
) error {
if !config.MDM.IsAppleSCEPSet() {
logger.Log("inf", "skipping renewal of macOS SCEP certificates as MDM is not fully configured")
return nil
}
if commander == nil {
logger.Log("inf", "skipping renewal of macOS SCEP certificates as apple_mdm.MDMAppleCommander was not provided")
return nil
}
// for each hash, grab the host that uses it as its identity certificate
certAssociations, err := ds.GetHostCertAssociationsToExpire(ctx, scepCertRenewalThresholdDays, maxCertsRenewalPerRun)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting host cert associations")
}
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting AppConfig")
}
mdmPushCertTopic, err := config.MDM.AppleAPNsTopic()
if err != nil {
return ctxerr.Wrap(ctx, err, "getting certificate topic")
}
// assocsWithRefs stores hosts that have enrollment references on their
// enrollment profiles. This is the case for ADE-enrolled hosts using
// SSO to authenticate.
assocsWithRefs := []fleet.SCEPIdentityAssociation{}
// assocsWithoutRefs stores hosts that don't have an enrollment
// reference in their enrollment profile.
assocsWithoutRefs := []fleet.SCEPIdentityAssociation{}
for _, assoc := range certAssociations {
if assoc.EnrollReference != "" {
assocsWithRefs = append(assocsWithRefs, assoc)
continue
}
assocsWithoutRefs = append(assocsWithoutRefs, assoc)
}
// send a single command for all the hosts without references.
if len(assocsWithoutRefs) > 0 {
profile, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
appConfig.OrgInfo.OrgName,
appConfig.ServerSettings.ServerURL,
config.MDM.AppleSCEPChallenge,
mdmPushCertTopic,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts without enroll reference")
}
cmdUUID := uuid.NewString()
var uuids []string
for _, assoc := range assocsWithoutRefs {
uuids = append(uuids, assoc.HostUUID)
assoc.RenewCommandUUID = cmdUUID
}
if err := commander.InstallProfile(ctx, uuids, profile, cmdUUID); err != nil {
return ctxerr.Wrapf(ctx, err, "sending InstallProfile command for hosts %s", assocsWithoutRefs)
}
if err := ds.SetCommandForPendingSCEPRenewal(ctx, assocsWithoutRefs, cmdUUID); err != nil {
return ctxerr.Wrap(ctx, err, "setting pending command associations")
}
}
// send individual commands for each host with a reference
for _, assoc := range assocsWithRefs {
enrollURL, err := apple_mdm.AddEnrollmentRefToFleetURL(appConfig.ServerSettings.ServerURL, assoc.EnrollReference)
if err != nil {
return ctxerr.Wrap(ctx, err, "adding reference to fleet URL")
}
profile, err := apple_mdm.GenerateEnrollmentProfileMobileconfig(
appConfig.OrgInfo.OrgName,
enrollURL,
config.MDM.AppleSCEPChallenge,
mdmPushCertTopic,
)
if err != nil {
return ctxerr.Wrap(ctx, err, "generating enrollment profile for hosts with enroll reference")
}
cmdUUID := uuid.NewString()
if err := commander.InstallProfile(ctx, []string{assoc.HostUUID}, profile, cmdUUID); err != nil {
return ctxerr.Wrapf(ctx, err, "sending InstallProfile command for hosts %s", assocsWithRefs)
}
if err := ds.SetCommandForPendingSCEPRenewal(ctx, []fleet.SCEPIdentityAssociation{assoc}, cmdUUID); err != nil {
return ctxerr.Wrap(ctx, err, "setting pending command associations")
}
}
return nil
}

View File

@ -3,17 +3,25 @@ package service
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/config"
@ -23,6 +31,7 @@ import (
fleetmdm "github.com/fleetdm/fleet/v4/server/mdm"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/log/stdlogfmt"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
"github.com/fleetdm/fleet/v4/server/mock"
@ -2656,3 +2665,248 @@ func mobileconfigForTestWithContent(outerName, outerIdentifier, innerIdentifier,
</plist>
`, innerName, innerIdentifier, innerType, outerName, outerIdentifier, uuid.New().String()))
}
func generateCertWithAPNsTopic() ([]byte, []byte, error) {
// generate a new private key
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, nil, err
}
// set up the OID for UID
oidUID := asn1.ObjectIdentifier{0, 9, 2342, 19200300, 100, 1, 1}
// set up a certificate template with the required UID in the Subject
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour)
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, err
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
ExtraNames: []pkix.AttributeTypeAndValue{
{
Type: oidUID,
Value: "com.apple.mgmt.Example",
},
},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// create a self-signed certificate
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return nil, nil, err
}
// encode to PEM
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
return certPEM, keyPEM, nil
}
func setupTest(t *testing.T) (context.Context, kitlog.Logger, *mock.Store, *config.FleetConfig, *mock.MDMAppleStore, *apple_mdm.MDMAppleCommander) {
ctx := context.Background()
logger := kitlog.NewNopLogger()
cfg := config.TestConfig()
testCertPEM, testKeyPEM, err := generateCertWithAPNsTopic()
require.NoError(t, err)
config.SetTestMDMConfig(t, &cfg, testCertPEM, testKeyPEM, testBMToken, "../../server/service/testdata")
ds := new(mock.Store)
mdmStorage := &mock.MDMAppleStore{}
pushFactory, _ := newMockAPNSPushProviderFactory()
pusher := nanomdm_pushsvc.New(
mdmStorage,
mdmStorage,
pushFactory,
stdlogfmt.New(),
)
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, pusher)
return ctx, logger, ds, &cfg, mdmStorage, commander
}
func TestRenewSCEPCertificatesMDMConfigNotSet(t *testing.T) {
ctx, logger, ds, cfg, _, commander := setupTest(t)
cfg.MDM = config.MDMConfig{} // ensure MDM is not fully configured
err := RenewSCEPCertificates(ctx, logger, ds, cfg, commander)
require.NoError(t, err)
}
func TestRenewSCEPCertificatesCommanderNil(t *testing.T) {
ctx, logger, ds, cfg, _, _ := setupTest(t)
err := RenewSCEPCertificates(ctx, logger, ds, cfg, nil)
require.NoError(t, err)
}
func TestRenewSCEPCertificatesBranches(t *testing.T) {
tests := []struct {
name string
customExpectations func(*testing.T, *mock.Store, *config.FleetConfig, *mock.MDMAppleStore, *apple_mdm.MDMAppleCommander)
expectedError bool
}{
{
name: "No Certs to Renew",
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
return nil, nil
}
},
expectedError: false,
},
{
name: "GetHostCertAssociationsToExpire Errors",
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
return nil, errors.New("database error")
}
},
expectedError: true,
},
{
name: "AppConfig Errors",
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
return nil, errors.New("app config error")
}
},
expectedError: true,
},
{
name: "InstallProfile for hostsWithoutRefs",
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
var wantCommandUUID string
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID1", EnrollReference: ""}}, nil
}
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
wantCommandUUID = cmd.CommandUUID
return map[string]error{}, nil
}
ds.SetCommandForPendingSCEPRenewalFunc = func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
require.Len(t, assocs, 1)
require.Equal(t, "hostUUID1", assocs[0].HostUUID)
require.Equal(t, cmdUUID, wantCommandUUID)
return nil
}
t.Cleanup(func() {
require.True(t, appleStore.EnqueueCommandFuncInvoked)
require.True(t, ds.SetCommandForPendingSCEPRenewalFuncInvoked)
})
},
expectedError: false,
},
{
name: "InstallProfile for hostsWithoutRefs fails",
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID1", EnrollReference: ""}}, nil
}
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
return map[string]error{}, errors.New("foo")
}
},
expectedError: true,
},
{
name: "InstallProfile for hostsWithRefs",
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
var wantCommandUUID string
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID2", EnrollReference: "ref1"}}, nil
}
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
require.Equal(t, "InstallProfile", cmd.Command.RequestType)
wantCommandUUID = cmd.CommandUUID
return map[string]error{}, nil
}
ds.SetCommandForPendingSCEPRenewalFunc = func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
require.Len(t, assocs, 1)
require.Equal(t, "hostUUID2", assocs[0].HostUUID)
require.Equal(t, cmdUUID, wantCommandUUID)
return nil
}
t.Cleanup(func() {
require.True(t, appleStore.EnqueueCommandFuncInvoked)
require.True(t, ds.SetCommandForPendingSCEPRenewalFuncInvoked)
})
},
expectedError: false,
},
{
name: "InstallProfile for hostsWithRefs fails",
customExpectations: func(t *testing.T, ds *mock.Store, cfg *config.FleetConfig, appleStore *mock.MDMAppleStore, commander *apple_mdm.MDMAppleCommander) {
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
return []fleet.SCEPIdentityAssociation{{HostUUID: "hostUUID1", EnrollReference: "ref1"}}, nil
}
appleStore.EnqueueCommandFunc = func(ctx context.Context, id []string, cmd *mdm.Command) (map[string]error, error) {
return map[string]error{}, errors.New("foo")
}
},
expectedError: true,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx, logger, ds, cfg, appleStorage, commander := setupTest(t)
ds.AppConfigFunc = func(ctx context.Context) (*fleet.AppConfig, error) {
appCfg := &fleet.AppConfig{}
appCfg.OrgInfo.OrgName = "fl33t"
appCfg.ServerSettings.ServerURL = "https://foo.example.com"
return appCfg, nil
}
ds.GetHostCertAssociationsToExpireFunc = func(ctx context.Context, expiryDays int, limit int) ([]fleet.SCEPIdentityAssociation, error) {
return []fleet.SCEPIdentityAssociation{}, nil
}
ds.SetCommandForPendingSCEPRenewalFunc = func(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
return nil
}
appleStorage.RetrievePushInfoFunc = func(ctx context.Context, targets []string) (map[string]*mdm.Push, error) {
pushes := make(map[string]*mdm.Push, len(targets))
for _, uuid := range targets {
pushes[uuid] = &mdm.Push{
PushMagic: "magic" + uuid,
Token: []byte("token" + uuid),
Topic: "topic" + uuid,
}
}
return pushes, nil
}
appleStorage.RetrievePushCertFunc = func(ctx context.Context, topic string) (*tls.Certificate, string, error) {
cert, err := tls.LoadX509KeyPair("./testdata/server.pem", "./testdata/server.key")
return &cert, "", err
}
tc.customExpectations(t, ds, cfg, appleStorage, commander)
err := RenewSCEPCertificates(ctx, logger, ds, cfg, commander)
if tc.expectedError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
})
}
}

View File

@ -85,6 +85,7 @@ type integrationMDMTestSuite struct {
onDEPScheduleDone func() // function called when depSchedule.Trigger() job completed
mdmStorage *mysql.NanoMDMStorage
worker *worker.Worker
mdmCommander *apple_mdm.MDMAppleCommander
}
func (s *integrationMDMTestSuite) SetupSuite() {
@ -200,6 +201,7 @@ func (s *integrationMDMTestSuite) SetupSuite() {
s.depSchedule = depSchedule
s.profileSchedule = profileSchedule
s.mdmStorage = mdmStorage
s.mdmCommander = mdmCommander
macosJob := &worker.MacosSetupAssistant{
Datastore: s.ds,
@ -7092,8 +7094,13 @@ func (s *integrationMDMTestSuite) downloadAndVerifyEnrollmentProfile(path string
require.NoError(t, err)
require.Equal(t, len(body), headerLen)
return s.verifyEnrollmentProfile(body, "")
}
func (s *integrationMDMTestSuite) verifyEnrollmentProfile(rawProfile []byte, enrollmentRef string) *enrollmentProfile {
t := s.T()
var profile enrollmentProfile
require.NoError(t, plist.Unmarshal(body, &profile))
require.NoError(t, plist.Unmarshal(rawProfile, &profile))
for _, p := range profile.PayloadContent {
switch p.PayloadType {
@ -7101,8 +7108,10 @@ func (s *integrationMDMTestSuite) downloadAndVerifyEnrollmentProfile(path string
require.Equal(t, s.getConfig().ServerSettings.ServerURL+apple_mdm.SCEPPath, p.PayloadContent.URL)
require.Equal(t, s.fleetCfg.MDM.AppleSCEPChallenge, p.PayloadContent.Challenge)
case "com.apple.mdm":
// Use Contains as the url may have query params
require.Contains(t, p.ServerURL, s.getConfig().ServerSettings.ServerURL+apple_mdm.MDMPath)
if enrollmentRef != "" {
require.Contains(t, p.ServerURL, enrollmentRef)
}
default:
require.Failf(t, "unrecognized payload type in enrollment profile: %s", p.PayloadType)
}
@ -11323,3 +11332,112 @@ func (s *integrationMDMTestSuite) TestZCustomConfigurationWebURL() {
func (s *integrationMDMTestSuite) TestGetManualEnrollmentProfile() {
s.downloadAndVerifyEnrollmentProfile("/api/latest/fleet/mdm/manual_enrollment_profile")
}
func (s *integrationMDMTestSuite) TestSCEPCertExpiration() {
t := s.T()
ctx := context.Background()
// ensure there's a token for automatic enrollments
s.mockDEPResponse(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"auth_session_token": "xyz"}`))
}))
s.runDEPSchedule()
// add a device that's manually enrolled
desktopToken := uuid.New().String()
manualHost := createOrbitEnrolledHost(t, "darwin", "h1", s.ds)
err := s.ds.SetOrUpdateDeviceAuthToken(context.Background(), manualHost.ID, desktopToken)
require.NoError(t, err)
manualEnrolledDevice := mdmtest.NewTestMDMClientAppleDesktopManual(s.server.URL, desktopToken)
manualEnrolledDevice.UUID = manualHost.UUID
err = manualEnrolledDevice.Enroll()
require.NoError(t, err)
// add a device that's automatically enrolled
automaticHost := createOrbitEnrolledHost(t, "darwin", "h2", s.ds)
depURLToken := loadEnrollmentProfileDEPToken(t, s.ds)
automaticEnrolledDevice := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
automaticEnrolledDevice.UUID = automaticHost.UUID
automaticEnrolledDevice.SerialNumber = automaticHost.HardwareSerial
err = automaticEnrolledDevice.Enroll()
require.NoError(t, err)
// add a device that's automatically enrolled with a server ref
automaticHostWithRef := createOrbitEnrolledHost(t, "darwin", "h3", s.ds)
automaticEnrolledDeviceWithRef := mdmtest.NewTestMDMClientAppleDEP(s.server.URL, depURLToken)
automaticEnrolledDeviceWithRef.UUID = automaticHostWithRef.UUID
automaticEnrolledDeviceWithRef.SerialNumber = automaticHostWithRef.HardwareSerial
err = automaticEnrolledDeviceWithRef.Enroll()
require.NoError(t, s.ds.SetOrUpdateMDMData(ctx, automaticHostWithRef.ID, false, true, s.server.URL, true, fleet.WellKnownMDMFleet, "foo"))
require.NoError(t, err)
cert, key, err := generateCertWithAPNsTopic()
require.NoError(t, err)
fleetCfg := config.TestConfig()
config.SetTestMDMConfig(s.T(), &fleetCfg, cert, key, testBMToken, "")
logger := kitlog.NewJSONLogger(os.Stdout)
// run without expired certs, no command enqueued
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
cmd, err := manualEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDeviceWithRef.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
// expire all the certs we just created
mysql.ExecAdhocSQL(t, s.ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `
UPDATE nano_cert_auth_associations
SET cert_not_valid_after = DATE_SUB(CURDATE(), INTERVAL 1 YEAR)
WHERE id IN (?, ?, ?)
`, manualHost.UUID, automaticHost.UUID, automaticHostWithRef.UUID)
return err
})
// generate a new config here so we can manipulate the certs.
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
checkRenewCertCommand := func(device *mdmtest.TestAppleMDMClient, enrollRef string) {
var renewCmd *micromdm.CommandPayload
cmd, err := device.Idle()
require.NoError(t, err)
for cmd != nil {
if cmd.Command.RequestType == "InstallProfile" {
renewCmd = cmd
}
cmd, err = device.Acknowledge(cmd.CommandUUID)
require.NoError(t, err)
}
require.NotNil(t, renewCmd)
s.verifyEnrollmentProfile(renewCmd.Command.InstallProfile.Payload, enrollRef)
}
checkRenewCertCommand(manualEnrolledDevice, "")
checkRenewCertCommand(automaticEnrolledDevice, "")
checkRenewCertCommand(automaticEnrolledDeviceWithRef, "foo")
// another cron run shouldn't enqueue more commands
err = RenewSCEPCertificates(ctx, logger, s.ds, &fleetCfg, s.mdmCommander)
require.NoError(t, err)
cmd, err = manualEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDevice.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
cmd, err = automaticEnrolledDeviceWithRef.Idle()
require.NoError(t, err)
require.Nil(t, cmd)
}