don't clear bootstrap token when doing MDM cert renewals (#43098)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #41167 

# Checklist for submitter

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

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.
See [Changes
files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files)
for more information.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- [x] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI for any necessary changes

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

# Release Notes

* **Bug Fixes**
* Fixed an issue preventing device wipes after certificate renewal. The
bootstrap token is now properly preserved during the certificate renewal
process, ensuring reliable device wipe operations following renewal.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Magnus Jensen 2026-04-13 15:37:05 -05:00 committed by GitHub
parent ed13c58ea7
commit 7bcc2c6894
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 216 additions and 94 deletions

View file

@ -0,0 +1 @@
* Fixed an issue where trying to wipe a device after its certificate was renewed could fail due to a missing bootstrap token. _Note: The device might still have wiped_

View file

@ -2,6 +2,8 @@ package mysql
import (
"context"
"database/sql"
"encoding/base64"
"fmt"
"log/slog"
"sync"
@ -26,6 +28,7 @@ func TestNanoMDMStorage(t *testing.T) {
{"TestGetPendingLockCommand", testGetPendingLockCommand},
{"TestEnqueueDeviceLockCommandRaceCondition", testEnqueueDeviceLockCommandRaceCondition},
{"TestEnqueueDeviceUnlockCommand", testEnqueueDeviceUnlockCommand},
{"TestStoreAuthenticatePreservesBootstrapTokenDuringSCEPRenewal", testStoreAuthenticatePreservesBootstrapTokenDuringSCEPRenewal},
}
for _, c := range cases {
@ -234,6 +237,128 @@ func testGetPendingLockCommand(t *testing.T, ds *Datastore) {
require.Empty(t, pin)
}
// testStoreAuthenticatePreservesBootstrapTokenDuringSCEPRenewal verifies that
// StoreAuthenticate does NOT clear the bootstrap token when a SCEP renewal is
// in progress (renew_command_uuid is set in nano_cert_auth_associations), and
// DOES clear it on a normal (re-)enrollment.
// See https://github.com/fleetdm/fleet/issues/41167
func testStoreAuthenticatePreservesBootstrapTokenDuringSCEPRenewal(t *testing.T, ds *Datastore) {
ctx := t.Context()
ns, err := ds.NewMDMAppleMDMStorage()
require.NoError(t, err)
deviceUUID := uuid.NewString()
// --- Set up device with a bootstrap token ---
// Insert into nano_devices with a bootstrap token.
bootstrapToken := base64.StdEncoding.EncodeToString([]byte("my-secret-bootstrap-token"))
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO nano_devices (id, serial_number, authenticate, authenticate_at, bootstrap_token_b64, bootstrap_token_at)
VALUES (?, 'SERIAL1', 'auth-raw', CURRENT_TIMESTAMP, ?, CURRENT_TIMESTAMP)`,
deviceUUID, bootstrapToken)
require.NoError(t, err)
// Insert a nano_enrollment so cert auth association can reference it.
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO nano_enrollments (id, device_id, type, topic, push_magic, token_hex, token_update_tally, last_seen_at)
VALUES (?, ?, 'Device', 'topic', 'magic', 'deadbeef', 1, NOW())`,
deviceUUID, deviceUUID)
require.NoError(t, err)
// Insert cert auth association (no SCEP renewal yet).
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO nano_cert_auth_associations (id, sha256) VALUES (?, '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef')`,
deviceUUID)
require.NoError(t, err)
// Helper to read bootstrap_token_b64 from nano_devices.
getBootstrapToken := func() sql.NullString {
var token sql.NullString
err := ds.writer(ctx).QueryRowContext(ctx,
`SELECT bootstrap_token_b64 FROM nano_devices WHERE id = ?`, deviceUUID,
).Scan(&token)
require.NoError(t, err)
return token
}
// Verify token is set.
token := getBootstrapToken()
require.True(t, token.Valid)
require.Equal(t, bootstrapToken, token.String)
// --- Case 1: Normal re-enrollment (no SCEP renewal) should clear the bootstrap token ---
authMsg := &mdm.Authenticate{
Enrollment: mdm.Enrollment{UDID: deviceUUID},
Raw: []byte("auth-raw-reenroll"),
}
authMsg.SerialNumber = "SERIAL1"
req := &mdm.Request{
EnrollID: &mdm.EnrollID{ID: deviceUUID, Type: mdm.Device},
Context: ctx,
}
err = ns.StoreAuthenticate(req, authMsg)
require.NoError(t, err)
token = getBootstrapToken()
require.False(t, token.Valid, "bootstrap token should be cleared on normal re-enrollment")
// --- Restore the bootstrap token for the next test case ---
_, err = ds.writer(ctx).ExecContext(ctx,
`UPDATE nano_devices SET bootstrap_token_b64 = ?, bootstrap_token_at = CURRENT_TIMESTAMP WHERE id = ?`,
bootstrapToken, deviceUUID)
require.NoError(t, err)
// --- Case 2: SCEP renewal in progress should preserve the bootstrap token ---
// Simulate SCEP renewal by inserting a nano_command and setting renew_command_uuid.
renewCmdUUID := uuid.NewString()
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO nano_commands (command_uuid, request_type, command) VALUES (?, 'InstallProfile', '<?xml version="1.0"?>')`,
renewCmdUUID)
require.NoError(t, err)
_, err = ds.writer(ctx).ExecContext(ctx,
`UPDATE nano_cert_auth_associations SET renew_command_uuid = ? WHERE id = ?`,
renewCmdUUID, deviceUUID)
require.NoError(t, err)
// Now call StoreAuthenticate again — this simulates the device checking in during SCEP renewal.
authMsg2 := &mdm.Authenticate{
Enrollment: mdm.Enrollment{UDID: deviceUUID},
Raw: []byte("auth-raw-scep-renewal"),
}
authMsg2.SerialNumber = "SERIAL1"
err = ns.StoreAuthenticate(req, authMsg2)
require.NoError(t, err)
token = getBootstrapToken()
require.True(t, token.Valid, "bootstrap token should be preserved during SCEP renewal")
require.Equal(t, bootstrapToken, token.String)
// --- Case 3: After SCEP renewal completes (renew_command_uuid cleared), token should be cleared again ---
_, err = ds.writer(ctx).ExecContext(ctx,
`UPDATE nano_cert_auth_associations SET renew_command_uuid = NULL WHERE id = ?`,
deviceUUID)
require.NoError(t, err)
authMsg3 := &mdm.Authenticate{
Enrollment: mdm.Enrollment{UDID: deviceUUID},
Raw: []byte("auth-raw-post-renewal"),
}
authMsg3.SerialNumber = "SERIAL1"
err = ns.StoreAuthenticate(req, authMsg3)
require.NoError(t, err)
token = getBootstrapToken()
require.False(t, token.Valid, "bootstrap token should be cleared after SCEP renewal completes")
}
func testEnqueueDeviceLockCommandRaceCondition(t *testing.T, ds *Datastore) {
ctx := context.Background()

View file

@ -142,6 +142,10 @@ func (s *MySQLStorage) StoreAuthenticate(r *mdm.Request, msg *mdm.Authenticate)
if r.Certificate != nil {
pemCert = cryptoutil.PEMCertificate(r.Certificate.Raw)
}
// When a device undergoes SCEP certificate renewal, it sends a new
// Authenticate message. We must preserve the existing bootstrap token
// during renewal; clearing it causes commands that depend on it (e.g.
// EraseDevice) to fail. See https://github.com/fleetdm/fleet/issues/41167
_, err := s.db.ExecContext(
r.Context, `
INSERT INTO nano_devices
@ -152,11 +156,19 @@ ON DUPLICATE KEY
UPDATE
identity_cert = VALUES(identity_cert),
serial_number = VALUES(serial_number),
bootstrap_token_b64 = NULL,
bootstrap_token_at = NULL,
bootstrap_token_b64 = IF(
EXISTS(SELECT 1 FROM nano_cert_auth_associations nca WHERE nca.id = ? AND nca.renew_command_uuid IS NOT NULL),
bootstrap_token_b64,
NULL
),
bootstrap_token_at = IF(
EXISTS(SELECT 1 FROM nano_cert_auth_associations nca WHERE nca.id = ? AND nca.renew_command_uuid IS NOT NULL),
bootstrap_token_at,
NULL
),
authenticate = VALUES(authenticate),
authenticate_at = CURRENT_TIMESTAMP;`,
r.ID, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw,
r.ID, pemCert, nullEmptyString(msg.SerialNumber), msg.Raw, r.ID, r.ID,
)
return err

View file

@ -1,3 +1,4 @@
CREATE TABLE nano_devices (
id VARCHAR(255) NOT NULL,
@ -5,13 +6,13 @@ CREATE TABLE nano_devices (
serial_number VARCHAR(127) NULL,
-- If the (iOS, iPadOS) device sent an UnlockToken in the TokenUpdate
-- TODO: Consider using a TEXT field and encoding the binary
unlock_token MEDIUMBLOB NULL,
unlock_token_at TIMESTAMP NULL,
-- If the (iOS, iPadOS) device sent an UnlockToken in the TokenUpdate
-- TODO: Consider using a TEXT field and encoding the binary
unlock_token MEDIUMBLOB NULL, unlock_token_at TIMESTAMP NULL,
-- The last raw Authenticate for this device
authenticate TEXT NOT NULL,
-- The last raw Authenticate for this device
authenticate TEXT NOT NULL,
authenticate_at TIMESTAMP NOT NULL,
-- The last raw TokenUpdate for this device
token_update TEXT NULL,
@ -47,12 +48,12 @@ CREATE TABLE nano_users (
user_short_name VARCHAR(255) NULL,
user_long_name VARCHAR(255) NULL,
-- The last raw TokenUpdate for this user
token_update TEXT NULL,
token_update_at TIMESTAMP NULL,
-- The last raw TokenUpdate for this user
token_update TEXT NULL, token_update_at TIMESTAMP NULL,
-- The last raw UserAuthenticate (and optional digest) for this user
user_authenticate TEXT NULL,
-- The last raw UserAuthenticate (and optional digest) for this user
user_authenticate TEXT NULL,
user_authenticate_at TIMESTAMP NULL,
user_authenticate_digest TEXT NULL,
user_authenticate_digest_at TIMESTAMP NULL,
@ -75,10 +76,9 @@ CREATE TABLE nano_users (
CHECK (user_authenticate_digest IS NULL OR user_authenticate_digest != '')
);
/* This table represents enrollments which are an amalgamation of
* both device and user enrollments.
*/
* both device and user enrollments.
*/
CREATE TABLE nano_enrollments (
-- The enrollment ID of this enrollment
id VARCHAR(255) NOT NULL,
@ -91,11 +91,12 @@ CREATE TABLE nano_enrollments (
-- NULL in the case of a device enrollment.
user_id VARCHAR(255) NULL,
-- Textual representation of the type of device enrollment.
type VARCHAR(31) NOT NULL,
-- Textual representation of the type of device enrollment.
type VARCHAR(31) NOT NULL,
-- The MDM APNs push trifecta.
topic VARCHAR(255) NOT NULL,
-- The MDM APNs push trifecta.
topic VARCHAR(255) NOT NULL,
push_magic VARCHAR(127) NOT NULL,
token_hex VARCHAR(255) NOT NULL, -- TODO: Perhaps just CHAR(64)?
@ -129,42 +130,43 @@ CREATE TABLE nano_enrollments (
CHECK (token_hex != '')
);
/* Commands stand alone. By themsevles they aren't associated with
* a device, a result (response), etc. Joining other tables is required
* for more context.
*/
* a device, a result (response), etc. Joining other tables is required
* for more context.
*/
CREATE TABLE nano_commands (
command_uuid VARCHAR(127) NOT NULL,
request_type VARCHAR(63) NOT NULL,
request_type VARCHAR(63) NOT NULL,
-- Raw command Plist
command MEDIUMTEXT NOT NULL,
command MEDIUMTEXT NOT NULL,
created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
updated_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
subtype ENUM('None','ProfileWithSecrets') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'None',
subtype ENUM('None', 'ProfileWithSecrets') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'None',
name varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
PRIMARY KEY (command_uuid),
CHECK (command_uuid != ''),
CHECK (request_type != ''),
CHECK (SUBSTRING(command FROM 1 FOR 5) = '<?xml')
CHECK (
SUBSTRING(
command
FROM 1 FOR 5
) = '<?xml'
)
);
/* Results are enrollment responses to device commands.
*
* The choice for the PK being just the enrollment ID and command UUID
* was under consideration. The PK could have included for example the
* status in which case we could have separate status updates for
* a NotNow vs. an Acknowledge. However this might be non-intuitive to
* then query against to find if a given command had a response or not
* (i.e. the queue view would be more complicated). In the end this
* means we lose insight into when NotNows happen once a command is
* Acknowledged.
*/
*
* The choice for the PK being just the enrollment ID and command UUID
* was under consideration. The PK could have included for example the
* status in which case we could have separate status updates for
* a NotNow vs. an Acknowledge. However this might be non-intuitive to
* then query against to find if a given command had a response or not
* (i.e. the queue view would be more complicated). In the end this
* means we lose insight into when NotNows happen once a command is
* Acknowledged.
*/
CREATE TABLE nano_command_results (
id VARCHAR(255) NOT NULL,
command_uuid VARCHAR(127) NOT NULL,
@ -187,42 +189,31 @@ CREATE TABLE nano_command_results (
REFERENCES nano_commands (command_uuid)
ON DELETE CASCADE ON UPDATE CASCADE,
-- considering not enforcing these CHECKs to make sure we always
-- capture results in the case they're malformed.
CHECK (status != ''),
-- considering not enforcing these CHECKs to make sure we always
-- capture results in the case they're malformed.
CHECK (status != ''),
INDEX (status),
CHECK (SUBSTRING(result FROM 1 FOR 5) = '<?xml')
);
CREATE TABLE nano_enrollment_queue (
id VARCHAR(255) NOT NULL,
id VARCHAR(255) NOT NULL,
command_uuid VARCHAR(127) NOT NULL,
active BOOLEAN NOT NULL DEFAULT 1,
active BOOLEAN NOT NULL DEFAULT 1,
priority TINYINT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id, command_uuid),
INDEX (priority DESC, created_at),
FOREIGN KEY (id)
REFERENCES nano_enrollments (id)
ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (command_uuid)
REFERENCES nano_commands (command_uuid)
ON DELETE CASCADE ON UPDATE CASCADE
FOREIGN KEY (id) REFERENCES nano_enrollments (id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY (command_uuid) REFERENCES nano_commands (command_uuid) ON DELETE CASCADE ON UPDATE CASCADE
);
/* An enrollment's queue is a view into commands, enrollment queued
* commands, and any results received. Outstanding queue items (i.e.
* those that have received no result yet) will have a status of NULL
* (due to the LEFT JOIN against results).
*/
* commands, and any results received. Outstanding queue items (i.e.
* those that have received no result yet) will have a status of NULL
* (due to the LEFT JOIN against results).
*/
CREATE OR REPLACE VIEW nano_view_queue AS
SELECT
q.id,
@ -238,15 +229,10 @@ SELECT
r.result
FROM
nano_enrollment_queue AS q
INNER JOIN nano_commands AS c
ON q.command_uuid = c.command_uuid
LEFT JOIN nano_command_results r
ON r.command_uuid = q.command_uuid AND r.id = q.id
ORDER BY
q.priority DESC,
q.created_at;
INNER JOIN nano_commands AS c ON q.command_uuid = c.command_uuid
LEFT JOIN nano_command_results r ON r.command_uuid = q.command_uuid
AND r.id = q.id
ORDER BY q.priority DESC, q.created_at;
CREATE TABLE nano_push_certs (
@ -255,13 +241,14 @@ CREATE TABLE nano_push_certs (
cert_pem TEXT NOT NULL,
key_pem TEXT NOT NULL,
/* stale_token is a simple value that coordinates push certificates
* across the SQL backend. The push service checks this value
* every time push info is requested. This value should be updated
* every time a push cert is updated (i.e. renwals) and so all
* push services using this table will know the certificate has
* changed and reload it. This is managed by the MySQL backend. */
stale_token INTEGER NOT NULL,
/* stale_token is a simple value that coordinates push certificates
* across the SQL backend. The push service checks this value
* every time push info is requested. This value should be updated
* every time a push cert is updated (i.e. renwals) and so all
* push services using this table will know the certificate has
* changed and reload it. This is managed by the MySQL backend. */
stale_token INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@ -273,17 +260,14 @@ CREATE TABLE nano_push_certs (
CHECK (SUBSTRING(key_pem FROM 1 FOR 5) = '-----')
);
CREATE TABLE nano_cert_auth_associations (
id VARCHAR(255) NOT NULL,
sha256 CHAR(64) NOT NULL,
id VARCHAR(255) NOT NULL,
sha256 CHAR(64) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
cert_not_valid_after timestamp NULL DEFAULT NULL,
renew_command_uuid VARCHAR(127) NULL,
PRIMARY KEY (id, sha256),
CHECK (id != ''),
CHECK (sha256 != '')
);
@ -293,10 +277,10 @@ CREATE TABLE nano_cert_auth_associations (
* but this is needed to support cross-references against ACME during nanomdm-only tests and because I couldn't find a cleaner way to do this
*/
CREATE TABLE acme_orders (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
issued_certificate_serial BIGINT DEFAULT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY idx_issued_certificate_serial (issued_certificate_serial)
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
issued_certificate_serial BIGINT DEFAULT NULL,
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY idx_issued_certificate_serial (issued_certificate_serial)
);