Fixed Android certificate enrollment failures caused by SCEP challenge expiration when devices were offline. (#38753)

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

Switched to issue the SCEP fleet challenge on demand instead of ahead of
time.

# Checklist for submitter

- [x] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/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

* **Bug Fixes**
* Resolved Android certificate enrollment failures caused by SCEP
challenge expiration during offline periods, improving enrollment
reliability when devices lack connectivity.

* **Improvements**
* Certificate challenges are now generated on-demand when requested by
devices, rather than pre-generated, enhancing offline enrollment
support.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Victor Lyuboslavsky
2026-01-28 10:33:37 -06:00
committed by GitHub
parent 49cc44011f
commit 32fd10fe52
9 changed files with 257 additions and 73 deletions
+7 -1
View File
@@ -15,13 +15,19 @@ import (
// NewChallenge generates a random, base64-encoded challenge and inserts it into the challenges
// table. It returns the generated challenge or an error if the insertion fails.
func (ds *Datastore) NewChallenge(ctx context.Context) (string, error) {
return newChallenge(ctx, ds.writer(ctx))
}
// newChallenge is a helper that generates and inserts a challenge using the provided executor.
// This allows challenge creation within transactions.
func newChallenge(ctx context.Context, exec sqlx.ExecerContext) (string, error) {
key := make([]byte, 24)
_, err := rand.Read(key)
if err != nil {
return "", err
}
challenge := base64.URLEncoding.EncodeToString(key)
_, err = ds.writer(ctx).ExecContext(ctx, `INSERT INTO challenges (challenge) VALUES (?)`, challenge)
_, err = exec.ExecContext(ctx, `INSERT INTO challenges (challenge) VALUES (?)`, challenge)
if err != nil {
return "", err
}
@@ -441,50 +441,28 @@ func (ds *Datastore) GetAndTransitionCertificateTemplatesToDelivering(
return result, err
}
// TransitionCertificateTemplatesToDelivered transitions templates from 'delivering' to 'delivered'
// and sets the fleet_challenge for each template.
func (ds *Datastore) TransitionCertificateTemplatesToDelivered(
ctx context.Context,
hostUUID string,
challenges map[uint]string, // certificateTemplateID -> challenge
) error {
if len(challenges) == 0 {
// TransitionCertificateTemplatesToDelivered transitions the specified templates from 'delivering' to 'delivered'.
// The fleet_challenge is cleared so a fresh one is generated when the device fetches the certificate template via
// GetOrCreateFleetChallengeForCertificateTemplate.
func (ds *Datastore) TransitionCertificateTemplatesToDelivered(ctx context.Context, hostUUID string, templateIDs []uint) error {
if len(templateIDs) == 0 {
return nil
}
// Build UPDATE with CASE for each template's challenge.
// This is called once per host, so the CASE size is bounded by templates per host (small).
// Using a single UPDATE per host is more efficient than individual updates when processing many hosts.
var caseStmt strings.Builder
args := make([]any, 0, len(challenges)*3+1) // CASE args + hostUUID + IN args
caseStmt.WriteString("CASE certificate_template_id ")
for templateID, challenge := range challenges {
caseStmt.WriteString("WHEN ? THEN ? ")
args = append(args, templateID, challenge)
}
caseStmt.WriteString("END")
// Add hostUUID for WHERE clause
args = append(args, hostUUID)
// Build IN clause for template IDs
inPlaceholders := make([]string, 0, len(challenges))
for templateID := range challenges {
inPlaceholders = append(inPlaceholders, "?")
args = append(args, templateID)
}
query := fmt.Sprintf(`
query, args, err := sqlx.In(fmt.Sprintf(`
UPDATE host_certificate_templates
SET
status = '%s',
fleet_challenge = %s,
fleet_challenge = NULL,
updated_at = NOW()
WHERE
host_uuid = ? AND
status = '%s' AND
certificate_template_id IN (%s)
`, fleet.CertificateTemplateDelivered, caseStmt.String(), fleet.CertificateTemplateDelivering, strings.Join(inPlaceholders, ","))
certificate_template_id IN (?)
`, fleet.CertificateTemplateDelivered, fleet.CertificateTemplateDelivering), hostUUID, templateIDs)
if err != nil {
return ctxerr.Wrap(ctx, err, "build transition to delivered query")
}
if _, err := ds.writer(ctx).ExecContext(ctx, query, args...); err != nil {
return ctxerr.Wrap(ctx, err, "transition to delivered")
@@ -649,8 +627,9 @@ func (ds *Datastore) GetAndroidCertificateTemplatesForRenewal(
}
// SetAndroidCertificateTemplatesForRenewal marks the specified certificate templates for renewal
// by setting status to 'pending', clearing validity fields, and generating a new UUID.
// by setting status to 'pending', clearing validity fields and fleet_challenge, and generating a new UUID.
// The new UUID signals to the Android agent that the certificate needs renewal.
// The fleet_challenge is cleared so a fresh one is generated when the device fetches the renewed certificate.
func (ds *Datastore) SetAndroidCertificateTemplatesForRenewal(
ctx context.Context,
templates []fleet.HostCertificateTemplateForRenewal,
@@ -677,6 +656,7 @@ func (ds *Datastore) SetAndroidCertificateTemplatesForRenewal(
not_valid_before = NULL,
not_valid_after = NULL,
serial = NULL,
fleet_challenge = NULL,
updated_at = NOW()
WHERE (host_uuid, certificate_template_id) IN (%s)
`, fleet.CertificateTemplatePending, placeholders.String())
@@ -687,3 +667,61 @@ func (ds *Datastore) SetAndroidCertificateTemplatesForRenewal(
return nil
}
// GetOrCreateFleetChallengeForCertificateTemplate ensures a fleet challenge exists for the given
// host and certificate template. If a challenge already exists in host_certificate_templates,
// it returns it. If not, it creates a new one atomically and stores it in both the challenges
// table (for validation) and host_certificate_templates (for retrieval).
// This method only works for templates in 'delivered' status.
func (ds *Datastore) GetOrCreateFleetChallengeForCertificateTemplate(
ctx context.Context,
hostUUID string,
certificateTemplateID uint,
) (string, error) {
var challenge string
err := ds.withRetryTxx(ctx, func(tx sqlx.ExtContext) error {
// Check if challenge already exists using FOR UPDATE to prevent race conditions
var existingChallenge sql.NullString
err := sqlx.GetContext(ctx, tx, &existingChallenge, fmt.Sprintf(`
SELECT fleet_challenge
FROM host_certificate_templates
WHERE host_uuid = ? AND certificate_template_id = ? AND status = '%s' AND operation_type = '%s'
FOR UPDATE
`, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall), hostUUID, certificateTemplateID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ctxerr.Wrap(ctx, notFound("HostCertificateTemplate"), "template not found or not in delivered status")
}
return ctxerr.Wrap(ctx, err, "check existing challenge")
}
// If challenge exists and is non-empty, return it
if existingChallenge.Valid && existingChallenge.String != "" {
challenge = existingChallenge.String
return nil
}
// Create new challenge using the transaction
newChal, err := newChallenge(ctx, tx)
if err != nil {
return ctxerr.Wrap(ctx, err, "create challenge")
}
// Update host_certificate_templates with the challenge
if _, err := tx.ExecContext(ctx, fmt.Sprintf(`
UPDATE host_certificate_templates
SET fleet_challenge = ?, updated_at = NOW()
WHERE host_uuid = ? AND certificate_template_id = ? AND status = '%s' AND operation_type = '%s'
`, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall), newChal, hostUUID, certificateTemplateID); err != nil {
return ctxerr.Wrap(ctx, err, "update fleet_challenge in host_certificate_templates")
}
challenge = newChal
return nil
})
if err != nil {
return "", err
}
return challenge, nil
}
@@ -40,6 +40,7 @@ func TestHostCertificateTemplates(t *testing.T) {
{"CertificateTemplateReinstalledAfterTransferBackToOriginalTeam", testCertificateTemplateReinstalledAfterTransferBackToOriginalTeam},
{"GetAndroidCertificateTemplatesForRenewal", testGetAndroidCertificateTemplatesForRenewal},
{"SetAndroidCertificateTemplatesForRenewal", testSetAndroidCertificateTemplatesForRenewal},
{"GetOrCreateFleetChallengeForCertificateTemplate", testGetOrCreateFleetChallengeForCertificateTemplate},
}
for _, c := range cases {
@@ -865,15 +866,30 @@ func testCertificateTemplateFullStateMachine(t *testing.T, ds *Datastore) {
require.EqualValues(t, fleet.CertificateTemplateDelivering, *r.Status)
}
// Step 4: Transition to delivered with challenges
challenges := map[uint]string{
setup.template.ID: "challenge-abc",
templateTwo.ID: "challenge-xyz",
}
err = ds.TransitionCertificateTemplatesToDelivered(ctx, "android-host", challenges)
// Step 4: Transition to delivered (challenges are created on-demand)
err = ds.TransitionCertificateTemplatesToDelivered(ctx, "android-host", []uint{setup.template.ID, templateTwo.ID})
require.NoError(t, err)
// Verify final state
// Verify delivered state (no challenges yet)
records, err = ds.ListCertificateTemplatesForHosts(ctx, []string{"android-host"})
require.NoError(t, err)
require.Len(t, records, 2)
for _, r := range records {
require.NotNil(t, r.Status)
require.EqualValues(t, fleet.CertificateTemplateDelivered, *r.Status)
require.Nil(t, r.FleetChallenge) // Challenge not created yet
}
// Step 5: Create challenges on-demand (simulating device fetch)
challenge1, err := ds.GetOrCreateFleetChallengeForCertificateTemplate(ctx, "android-host", setup.template.ID)
require.NoError(t, err)
require.NotEmpty(t, challenge1)
challenge2, err := ds.GetOrCreateFleetChallengeForCertificateTemplate(ctx, "android-host", templateTwo.ID)
require.NoError(t, err)
require.NotEmpty(t, challenge2)
// Verify challenges are now set
records, err = ds.ListCertificateTemplatesForHosts(ctx, []string{"android-host"})
require.NoError(t, err)
require.Len(t, records, 2)
@@ -882,9 +898,9 @@ func testCertificateTemplateFullStateMachine(t *testing.T, ds *Datastore) {
require.EqualValues(t, fleet.CertificateTemplateDelivered, *r.Status)
require.NotNil(t, r.FleetChallenge)
if r.CertificateTemplateID == setup.template.ID {
require.Equal(t, "challenge-abc", *r.FleetChallenge)
require.Equal(t, challenge1, *r.FleetChallenge)
} else {
require.Equal(t, "challenge-xyz", *r.FleetChallenge)
require.Equal(t, challenge2, *r.FleetChallenge)
}
}
@@ -1825,6 +1841,12 @@ func testSetAndroidCertificateTemplatesForRenewal(t *testing.T, ds *Datastore) {
insertHostCertTemplate(t, ds, host1.UUID, templateID, fleet.CertificateTemplateVerified, fleet.MDMOperationTypeInstall, &notValidBefore, &notValidAfter)
insertHostCertTemplate(t, ds, host2.UUID, templateID, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall, &notValidBefore, &notValidAfter)
// Set a fleet_challenge on host1 to verify it gets cleared during renewal
_, err = ds.writer(ctx).ExecContext(ctx,
`UPDATE host_certificate_templates SET fleet_challenge = 'old-challenge' WHERE host_uuid = ?`,
host1.UUID)
require.NoError(t, err)
// Get the original UUIDs
var originalUUIDs []struct {
HostUUID string `db:"host_uuid"`
@@ -1855,9 +1877,10 @@ func testSetAndroidCertificateTemplatesForRenewal(t *testing.T, ds *Datastore) {
NotValidBefore *string `db:"not_valid_before"`
NotValidAfter *string `db:"not_valid_after"`
Serial *string `db:"serial"`
FleetChallenge *string `db:"fleet_challenge"`
}
err = sqlx.SelectContext(ctx, ds.reader(ctx), &updatedRecords,
`SELECT host_uuid, status, COALESCE(BIN_TO_UUID(uuid, true), '') AS uuid, not_valid_before, not_valid_after, serial
`SELECT host_uuid, status, COALESCE(BIN_TO_UUID(uuid, true), '') AS uuid, not_valid_before, not_valid_after, serial, fleet_challenge
FROM host_certificate_templates WHERE host_uuid IN (?, ?) ORDER BY host_uuid`,
host1.UUID, host2.UUID)
require.NoError(t, err)
@@ -1878,9 +1901,109 @@ func testSetAndroidCertificateTemplatesForRenewal(t *testing.T, ds *Datastore) {
require.Nil(t, r.NotValidBefore, "not_valid_before should be cleared")
require.Nil(t, r.NotValidAfter, "not_valid_after should be cleared")
require.Nil(t, r.Serial, "serial should be cleared")
// Fleet challenge should be cleared so a new one is generated on next delivery
require.Nil(t, r.FleetChallenge, "fleet_challenge should be cleared")
}
// Test empty slice doesn't error
err = ds.SetAndroidCertificateTemplatesForRenewal(ctx, []fleet.HostCertificateTemplateForRenewal{})
require.NoError(t, err)
}
func testGetOrCreateFleetChallengeForCertificateTemplate(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Create test setup
team, err := ds.NewTeam(ctx, &fleet.Team{Name: "test team challenge"})
require.NoError(t, err)
ca, err := ds.NewCertificateAuthority(ctx, &fleet.CertificateAuthority{
Name: ptr.String("test ca challenge"),
Type: string(fleet.CAConfigCustomSCEPProxy),
URL: ptr.String("http://localhost:8080/scep"),
})
require.NoError(t, err)
now := time.Now().UTC()
host := test.NewHost(t, ds, "host-challenge", "192.168.1.1", "host_key_challenge", uuid.NewString(), now, test.WithPlatform("android"), test.WithTeamID(team.ID))
t.Run("returns error for non-existent template", func(t *testing.T) {
_, err := ds.GetOrCreateFleetChallengeForCertificateTemplate(ctx, host.UUID, 99999)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
})
t.Run("returns error for non-delivered status", func(t *testing.T) {
// Create a separate template for this test
pendingTemplate, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
TeamID: team.ID,
Name: "test template pending",
CertificateAuthorityID: ca.ID,
SubjectName: "CN=test-pending",
})
require.NoError(t, err)
// Insert a pending certificate template
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO host_certificate_templates
(host_uuid, certificate_template_id, status, operation_type, name, uuid)
VALUES (?, ?, ?, ?, 'test', UUID_TO_BIN(UUID(), true))`,
host.UUID, pendingTemplate.ID, fleet.CertificateTemplatePending, fleet.MDMOperationTypeInstall)
require.NoError(t, err)
_, err = ds.GetOrCreateFleetChallengeForCertificateTemplate(ctx, host.UUID, pendingTemplate.ID)
require.Error(t, err)
require.True(t, fleet.IsNotFound(err))
})
t.Run("creates challenge on first call and returns same on subsequent calls", func(t *testing.T) {
template, err := ds.CreateCertificateTemplate(ctx, &fleet.CertificateTemplate{
TeamID: team.ID,
Name: "test template challenge",
CertificateAuthorityID: ca.ID,
SubjectName: "CN=test",
})
require.NoError(t, err)
// Insert a delivered certificate template WITHOUT a challenge
_, err = ds.writer(ctx).ExecContext(ctx,
`INSERT INTO host_certificate_templates
(host_uuid, certificate_template_id, status, operation_type, name, uuid, fleet_challenge)
VALUES (?, ?, ?, ?, 'test', UUID_TO_BIN(UUID(), true), NULL)`,
host.UUID, template.ID, fleet.CertificateTemplateDelivered, fleet.MDMOperationTypeInstall)
require.NoError(t, err)
// First call should create a challenge
challenge, err := ds.GetOrCreateFleetChallengeForCertificateTemplate(ctx, host.UUID, template.ID)
require.NoError(t, err)
require.NotEmpty(t, challenge)
require.Len(t, challenge, 32) // Base64 encoded 24 bytes
// Verify challenge was stored in host_certificate_templates
var storedChallenge string
err = sqlx.GetContext(ctx, ds.reader(ctx), &storedChallenge,
`SELECT fleet_challenge FROM host_certificate_templates WHERE host_uuid = ? AND certificate_template_id = ?`,
host.UUID, template.ID)
require.NoError(t, err)
require.Equal(t, challenge, storedChallenge)
// Verify challenge was also inserted into challenges table
var createdAt time.Time
err = sqlx.GetContext(ctx, ds.reader(ctx), &createdAt,
`SELECT created_at FROM challenges WHERE challenge = ?`, challenge)
require.NoError(t, err)
require.WithinDuration(t, time.Now(), createdAt, 5*time.Second)
// Subsequent call should return the same challenge
challenge2, err := ds.GetOrCreateFleetChallengeForCertificateTemplate(ctx, host.UUID, template.ID)
require.NoError(t, err)
require.Equal(t, challenge, challenge2)
// Verify only one challenge exists in challenges table
var count int
err = sqlx.GetContext(ctx, ds.reader(ctx), &count,
`SELECT COUNT(*) FROM challenges WHERE challenge = ?`, challenge)
require.NoError(t, err)
require.Equal(t, 1, count)
})
}