ACME MDM -> main (#42926)

<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** The entire ACME feature branch merge

# 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

## Testing

- [x] Added/updated automated tests
- [x] Where appropriate, [automated tests simulate multiple hosts and
test for host
isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing)
(updates to one hosts's records do not affect another)

- [x] QA'd all new/changed functionality manually

---------

Co-authored-by: Jordan Montgomery <elijah.jordan.montgomery@gmail.com>
Co-authored-by: Martin Angers <martin.n.angers@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com>
Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com>
This commit is contained in:
Magnus Jensen 2026-04-02 15:56:31 -05:00 committed by GitHub
parent 4c573f13d0
commit d4f48b6f9c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
123 changed files with 9396 additions and 498 deletions

View file

@ -0,0 +1 @@
* Implemented ACME for MDM protocol communication, and hardware device attestation.

View file

@ -23,6 +23,7 @@ import (
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm"
acme_api "github.com/fleetdm/fleet/v4/server/mdm/acme/api"
"github.com/fleetdm/fleet/v4/server/mdm/android"
android_svc "github.com/fleetdm/fleet/v4/server/mdm/android/service"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
@ -957,6 +958,7 @@ func newCleanupsAndAggregationSchedule(
softwareTitleIconStore fleet.SoftwareTitleIconStore,
androidSvc android.Service,
activitySvc activity_api.Service,
acmeSvc acme_api.Service,
) (*schedule.Schedule, error) {
const (
name = string(fleet.CronCleanupsThenAggregation)
@ -1093,7 +1095,7 @@ func newCleanupsAndAggregationSchedule(
schedule.WithJob(
"renew_scep_certificates",
func(ctx context.Context) error {
return service.RenewSCEPCertificates(ctx, logger, ds, config, commander)
return service.RenewSCEPCertificates(ctx, logger, ds, config, commander, acmeSvc)
},
),
schedule.WithJob("renew_host_mdm_managed_certificates", func(ctx context.Context) error {

View file

@ -6,6 +6,7 @@ import (
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"crypto/x509"
"database/sql/driver"
"errors"
"fmt"
@ -35,6 +36,7 @@ import (
"github.com/fleetdm/fleet/v4/pkg/scripts"
"github.com/fleetdm/fleet/v4/pkg/str"
"github.com/fleetdm/fleet/v4/server"
"github.com/fleetdm/fleet/v4/server/acl/acmeacl"
"github.com/fleetdm/fleet/v4/server/acl/activityacl"
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap"
@ -59,6 +61,9 @@ import (
"github.com/fleetdm/fleet/v4/server/live_query"
"github.com/fleetdm/fleet/v4/server/logging"
"github.com/fleetdm/fleet/v4/server/mail"
"github.com/fleetdm/fleet/v4/server/mdm/acme"
acme_api "github.com/fleetdm/fleet/v4/server/mdm/acme/api"
acme_bootstrap "github.com/fleetdm/fleet/v4/server/mdm/acme/bootstrap"
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps"
@ -67,6 +72,7 @@ import (
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/buford"
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
scepdepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
platform_logging "github.com/fleetdm/fleet/v4/server/platform/logging"
@ -76,6 +82,7 @@ import (
"github.com/fleetdm/fleet/v4/server/service/async"
"github.com/fleetdm/fleet/v4/server/service/conditional_access_microsoft_proxy"
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
"github.com/fleetdm/fleet/v4/server/service/middleware/log"
otelmw "github.com/fleetdm/fleet/v4/server/service/middleware/otel"
"github.com/fleetdm/fleet/v4/server/service/redis_key_value"
"github.com/fleetdm/fleet/v4/server/service/redis_lock"
@ -1098,6 +1105,12 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
// Inject the activity bounded context into the main service
svc.SetActivityService(activitySvc)
// Bootstrap ACME service module
acmeSigner := &acmeCSRSigner{signer: scepdepot.NewSigner(scepStorage, scepdepot.WithValidityDays(config.MDM.AppleSCEPSignerValidityDays), scepdepot.WithAllowRenewalDays(14))}
acmeSvc, acmeRoutes := createACMEServiceModule(ds, dbConns, redisPool, logger, acmeSigner)
// Inject the ACME service module into the main service
svc.SetACMEService(acmeSvc)
// Perform a cleanup of cron_stats outside of the cronSchedules because the
// schedule package uses cron_stats entries to decide whether a schedule will
// run or not (see https://github.com/fleetdm/fleet/issues/9486).
@ -1157,7 +1170,7 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
func() (fleet.CronSchedule, error) {
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
return newCleanupsAndAggregationSchedule(
ctx, instanceID, ds, svc, logger, redisWrapperDS, &config, commander, softwareInstallStore, bootstrapPackageStore, softwareTitleIconStore, androidSvc, activitySvc,
ctx, instanceID, ds, svc, logger, redisWrapperDS, &config, commander, softwareInstallStore, bootstrapPackageStore, softwareTitleIconStore, androidSvc, activitySvc, acmeSvc,
)
},
); err != nil {
@ -1468,7 +1481,7 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
extra = append(extra, service.WithHTTPSigVerifier(httpSigVerifier))
apiHandler = service.MakeHandler(svc, config, httpLogger, limiterStore, redisPool, carveStore,
[]endpointer.HandlerRoutesFunc{android_service.GetRoutes(svc, androidSvc), activityRoutes}, extra...)
[]endpointer.HandlerRoutesFunc{android_service.GetRoutes(svc, androidSvc), activityRoutes, acmeRoutes}, extra...)
if serveCSP {
// Only injecting this if CSP is turned on since the default security headers add some overhead to each request
@ -1871,6 +1884,22 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
logger.InfoContext(ctx, "terminated", "err", <-errs)
}
// acmeCSRSigner adapts a depot.Signer to the acme.CSRSigner interface.
type acmeCSRSigner struct {
signer *scepdepot.Signer
}
func (a *acmeCSRSigner) SignCSR(_ context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error) {
return a.signer.Signx509CSR(csr)
}
func createACMEServiceModule(ds fleet.Datastore, dbConns *common_mysql.DBConnections, redisPool fleet.RedisPool, logger *slog.Logger, csrSigner acme.CSRSigner) (acme_api.Service, endpointer.HandlerRoutesFunc) {
providers := acmeacl.NewFleetDatastoreAdapter(ds, csrSigner)
acmeSvc, acmeRoutesFn := acme_bootstrap.New(dbConns, redisPool, providers, logger)
acmeRoutes := acmeRoutesFn(log.Logged)
return acmeSvc, acmeRoutes
}
func createActivityBoundedContext(svc fleet.Service, dbConns *common_mysql.DBConnections, logger *slog.Logger) (activity_api.Service, endpointer.HandlerRoutesFunc) {
legacyAuthorizer, err := authz.NewAuthorizer()
if err != nil {

View file

@ -1320,6 +1320,7 @@ func (cmd *GenerateGitopsCommand) generateControls(teamId *uint, teamName string
if cmd.AppConfig.MDM.WindowsEnabledAndConfigured && len(cmd.AppConfig.MDM.WindowsEntraTenantIDs.Value) > 0 {
result[jsonFieldName(mdmT, "WindowsEntraTenantIDs")] = cmd.AppConfig.MDM.WindowsEntraTenantIDs.Value
}
result[jsonFieldName(mdmT, "AppleRequireHardwareAttestation")] = cmd.AppConfig.MDM.AppleRequireHardwareAttestation
}
if cmd.AppConfig.MDM.WindowsEnabledAndConfigured {
result["windows_enabled_and_configured"] = cmd.AppConfig.MDM.WindowsEnabledAndConfigured

View file

@ -263,8 +263,8 @@ func TestMDMRunCommand(t *testing.T) {
}
return res, nil
}
ds.GetNanoMDMEnrollmentTimesFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, error) {
return nil, nil, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
}
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
return false, nil
@ -1401,8 +1401,8 @@ func setupDSMocks(ds *mock.Store, hostByUUID map[string]testhost, hostsByID map[
ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) {
return nil, nil
}
ds.GetNanoMDMEnrollmentTimesFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, error) {
return nil, nil, nil
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
return nil, nil, false, nil
}
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
h, ok := hostsByID[hostID]

View file

@ -141,6 +141,7 @@
"enable_turn_on_windows_mdm_manually": false,
"windows_entra_tenant_ids": null,
"windows_require_bitlocker_pin": null,
"apple_require_hardware_attestation": false,
"macos_migration": {
"enable": false,
"mode": "",

View file

@ -113,6 +113,7 @@
"windows_migration_enabled": false,
"enable_turn_on_windows_mdm_manually": false,
"windows_entra_tenant_ids": null,
"apple_require_hardware_attestation": false,
"macos_migration": {
"enable": false,
"mode": "",

View file

@ -42,6 +42,7 @@ spec:
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: null
apple_require_hardware_attestation: false
macos_migration:
enable: false
mode: ""

View file

@ -42,6 +42,7 @@ spec:
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: null
apple_require_hardware_attestation: false
macos_migration:
enable: false
mode: ""

View file

@ -91,6 +91,7 @@
},
"windows_migration_enabled": false,
"enable_turn_on_windows_mdm_manually": false,
"apple_require_hardware_attestation": false,
"macos_migration": {
"enable": false,
"mode": "",

View file

@ -41,6 +41,7 @@ spec:
windows_require_bitlocker_pin: null
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
apple_require_hardware_attestation: false
windows_entra_tenant_ids: null
macos_migration:
enable: false

View file

@ -51,6 +51,7 @@
"pending_action": "",
"server_url": null
},
"mdm_enrollment_hardware_attested": false,
"memory": 0,
"orbit_version": null,
"os_version": "",

View file

@ -49,6 +49,8 @@ spec:
name: ""
pending_action: ""
server_url: null
mdm_enrollment_hardware_attested: false
memory: 0
orbit_version: null
os_version: ""

View file

@ -255,6 +255,7 @@
"5b84b6dd-d257-415e-b8b4-0240666ba4d4",
"9d30f55f-d117-4574-acf0-ff593e3e06e3"
],
"apple_require_hardware_attestation": false,
"end_user_authentication": {
"entity_id": "some-mdm-entity-id.com",
"issuer_uri": "https://some-mdm-issuer-uri.com",

View file

@ -37,6 +37,7 @@ windows_entra_tenant_ids:
windows_migration_enabled: true
enable_turn_on_windows_mdm_manually: false
windows_require_bitlocker_pin: false
apple_require_hardware_attestation: false
android_enabled_and_configured: true
enable_disk_encryption: true
enable_recovery_lock_password: false

View file

@ -1,5 +1,6 @@
controls:
android_enabled_and_configured: true
apple_require_hardware_attestation: false
enable_disk_encryption: true
enable_recovery_lock_password: false
enable_turn_on_windows_mdm_manually: false

View file

@ -17,6 +17,7 @@ controls:
windows_enabled_and_configured: false
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null

View file

@ -17,6 +17,7 @@ controls:
windows_enabled_and_configured: false
windows_migration_enabled: true
enable_turn_on_windows_mdm_manually: false
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null

View file

@ -17,6 +17,7 @@ controls:
windows_enabled_and_configured: true
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null

View file

@ -17,6 +17,7 @@ controls:
windows_enabled_and_configured: true
windows_migration_enabled: true
enable_turn_on_windows_mdm_manually: false
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null

View file

@ -42,6 +42,7 @@ spec:
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: null
apple_require_hardware_attestation: false
macos_migration:
enable: false
mode: ""

View file

@ -42,6 +42,7 @@ spec:
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: null
apple_require_hardware_attestation: false
macos_migration:
enable: false
mode: ""

View file

@ -13,6 +13,7 @@ const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = {
apple_bm_terms_expired: false,
enabled_and_configured: true,
android_enabled_and_configured: false,
apple_require_hardware_attestation: false,
macos_updates: {
minimum_version: "",
deadline: "",

View file

@ -66,6 +66,7 @@ export interface IMdmConfig {
enable_turn_on_windows_mdm_manually: boolean;
windows_migration_enabled: boolean;
android_enabled_and_configured: boolean;
apple_require_hardware_attestation: boolean;
end_user_authentication: IEndUserAuthentication;
macos_updates: IAppleDeviceUpdates;
ios_updates: IAppleDeviceUpdates;

View file

@ -351,6 +351,7 @@ export interface IHost {
/** There will be at most 1 end user */
end_users?: IHostEndUser[];
conditional_access_bypassed: boolean;
mdm_enrollment_hardware_attested?: boolean;
}
/*

View file

@ -34,6 +34,7 @@ interface IAdvancedConfigFormData {
disableScripts: boolean;
disableAIFeatures: boolean;
disableQueryReports: boolean;
requireHardwareAttestation: boolean;
}
interface IAdvancedConfigFormErrors {
@ -105,6 +106,8 @@ const Advanced = ({
disableAIFeatures: appConfig.server_settings.ai_features_disabled || false,
disableQueryReports:
appConfig.server_settings.query_reports_disabled || false,
requireHardwareAttestation:
appConfig.mdm?.apple_require_hardware_attestation || false,
});
const {
@ -121,6 +124,7 @@ const Advanced = ({
disableScripts,
disableAIFeatures,
disableQueryReports,
requireHardwareAttestation,
} = formData;
const [formErrors, setFormErrors] = useState<IAdvancedConfigFormErrors>({});
@ -190,6 +194,7 @@ const Advanced = ({
},
mdm: {
apple_server_url: mdmAppleServerURL,
apple_require_hardware_attestation: requireHardwareAttestation,
},
sso_settings: {
sso_server_url: ssoUserURL,
@ -517,6 +522,21 @@ const Advanced = ({
</Checkbox>
)}
/>
<GitOpsModeTooltipWrapper
position="left"
renderChildren={(disableChildren) => (
<Checkbox
disabled={disableChildren}
onChange={onInputChange}
name="requireHardwareAttestation"
value={requireHardwareAttestation}
parseTarget
helpText="Enabling this setting will require macOS hosts with Apple Silicon that automatically enroll (DEP) to use ACME with Managed Device Attestation"
>
Require hardware attestation
</Checkbox>
)}
/>
</div>
<Button
type="submit"

View file

@ -240,6 +240,41 @@ describe("Vitals Card component", () => {
});
});
describe("MDM attestation", () => {
it("renders MDM attestation when mdm_enrollment_hardware_attested is true", () => {
const mockHost = createMockHost({
platform: "darwin",
mdm_enrollment_hardware_attested: true,
});
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
expect(screen.getByText("MDM attestation")).toBeInTheDocument();
expect(screen.getByText("Yes")).toBeInTheDocument();
});
it("does not render MDM attestation when mdm_enrollment_hardware_attested is false", () => {
const mockHost = createMockHost({
platform: "darwin",
mdm_enrollment_hardware_attested: false,
});
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
expect(screen.queryByText("MDM attestation")).not.toBeInTheDocument();
});
it("does not render MDM attestation when mdm_enrollment_hardware_attested is undefined", () => {
const mockHost = createMockHost({
platform: "darwin",
});
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
expect(screen.queryByText("MDM attestation")).not.toBeInTheDocument();
});
});
describe("Disk encryption data", () => {
it("renders 'On' for macOS when enabled", () => {
const mockHost = createMockHost({

View file

@ -123,6 +123,7 @@ const Vitals = ({
const {
platform,
os_version,
mdm_enrollment_hardware_attested,
disk_encryption_enabled: diskEncryptionEnabled,
} = vitalsData;
@ -386,6 +387,24 @@ const Vitals = ({
});
}
// MDM attestation
if (mdm_enrollment_hardware_attested) {
vitals.push({
sortKey: "MDM attestation",
element: (
<DataSet
key="mdm-attestation"
title="MDM attestation"
value={
<TooltipWrapper tipContent="Host provided a Managed Device Attestation signed by Apple at enrollment.">
Yes
</TooltipWrapper>
}
/>
),
});
}
// MDM
if (mdm?.enrollment_status) {
vitals.push(

View file

@ -457,6 +457,7 @@ export const HOST_VITALS_DATA = [
"cpu_type",
"os_version",
"timezone",
"mdm_enrollment_hardware_attested",
"primary_mac",
];

65
go.mod
View file

@ -3,7 +3,7 @@ module github.com/fleetdm/fleet/v4
go 1.26.1
require (
cloud.google.com/go/pubsub v1.49.0
cloud.google.com/go/pubsub v1.50.1
fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d
github.com/AbGuthrie/goquery/v2 v2.0.1
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
@ -18,9 +18,9 @@ require (
github.com/agnivade/levenshtein v1.2.1
github.com/andygrunwald/go-jira v1.16.0
github.com/antchfx/xmlquery v1.3.14
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2 v1.41.4
github.com/aws/aws-sdk-go-v2/config v1.32.12
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign v1.8.3
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.16
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.81
@ -30,8 +30,8 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.81.0
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8
github.com/aws/aws-sdk-go-v2/service/ses v1.30.4
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5
github.com/aws/smithy-go v1.24.0
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9
github.com/aws/smithy-go v1.24.2
github.com/beevik/etree v1.6.0
github.com/beevik/ntp v0.3.0
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
@ -58,6 +58,7 @@ require (
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c
github.com/fatih/color v1.16.0
github.com/foxboron/go-tpm-keyfiles v0.0.0-20250520203025-c3c3a4ec1653
github.com/fxamacker/cbor/v2 v2.9.1
github.com/getsentry/sentry-go v0.18.0
github.com/ghodss/yaml v1.0.0
github.com/go-git/go-git/v5 v5.17.1
@ -163,18 +164,19 @@ require (
go.opentelemetry.io/otel/sdk/log v0.16.0
go.opentelemetry.io/otel/sdk/metric v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
go.step.sm/crypto v0.77.1
golang.org/x/crypto v0.49.0
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
golang.org/x/image v0.38.0
golang.org/x/mod v0.33.0
golang.org/x/net v0.51.0
golang.org/x/oauth2 v0.34.0
golang.org/x/net v0.52.0
golang.org/x/oauth2 v0.35.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.42.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
golang.org/x/tools v0.42.0
google.golang.org/api v0.256.0
google.golang.org/api v0.269.0
google.golang.org/grpc v1.79.3
gopkg.in/guregu/null.v3 v3.5.0
gopkg.in/ini.v1 v1.67.0
@ -185,14 +187,15 @@ require (
)
require (
cloud.google.com/go v0.120.0 // indirect
cloud.google.com/go/auth v0.17.0 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.2 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/pubsub/v2 v2.0.0 // indirect
cyphar.com/go-pathrs v0.2.1 // indirect
dario.cat/mergo v1.0.0 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 // indirect
github.com/AlekSi/pointer v1.2.0 // indirect
@ -209,18 +212,18 @@ require (
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
github.com/armon/go-radix v1.0.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/c-bata/go-prompt v0.2.3 // indirect
github.com/cavaliergopher/cpio v1.0.1 // indirect
@ -260,6 +263,7 @@ require (
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-kit/log v0.2.1 // indirect
github.com/go-logfmt/logfmt v0.5.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@ -271,10 +275,10 @@ require (
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm-tools v0.4.5 // indirect
github.com/google/go-tpm-tools v0.4.7 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
github.com/goreleaser/chglog v0.4.2 // indirect
github.com/goreleaser/fileglob v1.3.0 // indirect
github.com/gorilla/schema v1.4.1 // indirect
@ -283,7 +287,7 @@ require (
github.com/hashicorp/go-hclog v1.6.3 // indirect
github.com/hashicorp/go-version v1.7.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/imdario/mergo v0.3.15 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
@ -344,6 +348,7 @@ require (
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/trivago/tgo v1.0.7 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
@ -359,9 +364,9 @@ require (
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

160
go.sum
View file

@ -1,26 +1,28 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk=
cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8=
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
cloud.google.com/go/pubsub v1.49.0 h1:5054IkbslnrMCgA2MAEPcsN3Ky+AyMpEZcii/DoySPo=
cloud.google.com/go/pubsub v1.49.0/go.mod h1:K1FswTWP+C1tI/nfi3HQecoVeFvL4HUOB1tdaNXKhUY=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU=
cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58=
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
cloud.google.com/go/pubsub v1.50.1 h1:fzbXpPyJnSGvWXF1jabhQeXyxdbCIkXTpjXHy7xviBM=
cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk=
cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0=
cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E=
cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8=
cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d h1:NjHwOOuOgGswUOPzDlsEDJOqKdjOjwL8Vi1mj9qx9+o=
fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/AbGuthrie/goquery/v2 v2.0.1 h1:h0tIhmeRroyqYjT9zxXPXOrheNp1xqNTV+XFWuDI+eA=
@ -109,38 +111,38 @@ github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign v1.8.3 h1:/d7ZHq/2m+1Uzw4mnizCZbTAWB/dJ3CPy0N1qUpUpI0=
github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign v1.8.3/go.mod h1:xWMYk6dLhV33jy2YrbOsv2l3fZTDMWE1yIIbvnD13gU=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.16 h1:LFB4eCU2S9wpFAkEnSqtP8CgdOk0cjMIzuXas1+rbWM=
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.16/go.mod h1:Q7hjCcQzFZ9QgZ+xeJhO4X1rv7uKAl4aoBEjab6MS8k=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.81 h1:E5ff1vZlAudg24j5lF6F6/gBpln2LjWxGdQDBSLfVe4=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.81/go.mod h1:hHBLCuhHI4Aokvs5vdVoCDBzmFy86yxs5J7LEPQwQEM=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw=
github.com/aws/aws-sdk-go-v2/service/firehose v1.37.7 h1:rDNxf0CQboBMqzm6WmhGL58pYpKMjU6Qs3/BfY3Em4Y=
github.com/aws/aws-sdk-go-v2/service/firehose v1.37.7/go.mod h1:E1yDRkUMwlVGmDYcu5UJuwfznGNuVW29sjr2xxM2Y0w=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U=
github.com/aws/aws-sdk-go-v2/service/kinesis v1.35.3 h1:aAi9YBNpYMEX52Z9qy1YP2t3RhDqMcP67Ep/C4q5RiQ=
@ -153,16 +155,16 @@ github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8 h1:HD6R8K10gPbN9CNqR
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8/go.mod h1:x66GdH8qjYTr6Kb4ik38Ewl6moLsg8igbceNsmxVxeA=
github.com/aws/aws-sdk-go-v2/service/ses v1.30.4 h1:VT+yYtHKQiDJrNAsvoO2ExMUN3KxWsFRt+S5j1MdFGk=
github.com/aws/aws-sdk-go-v2/service/ses v1.30.4/go.mod h1:Zftob00wu8O9xWSN1pdczm1U+E6yXk9znf+4lkt+3aQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
@ -347,6 +349,8 @@ github.com/freddierice/go-losetup/v2 v2.0.1/go.mod h1:TEyBrvlOelsPEhfWD5rutNXDmU
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 h1:GOfMz6cRgTJ9jWV0qAezv642OhPnKEG7gtUjJSdStHE=
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17/go.mod h1:HfkOCN6fkKKaPSAeNq/er3xObxTW4VLeY6UUK895gLQ=
github.com/getsentry/sentry-go v0.18.0 h1:MtBW5H9QgdcJabtZcuJG80BMOwaBpkRDZkxRkNC1sN0=
@ -367,6 +371,8 @@ github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5
github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 h1:o8UqXPI6SVwQt04RGsqKp3qqmbOfTNMqDrWsc4O47kk=
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/kit v0.4.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@ -466,14 +472,14 @@ github.com/google/go-github/v37 v37.0.0/go.mod h1:LM7in3NmXDrX58GbEHy7FtNLbI2Jij
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-sev-guest v0.12.1 h1:H4rFYnPIn8HtqEsNTmh56Zxcf9BI9n48ZSYCnpYLYvc=
github.com/google/go-sev-guest v0.12.1/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760=
github.com/google/go-sev-guest v0.14.0 h1:dCb4F3YrHTtrDX3cYIPTifEDz7XagZmXQioxRBW4wOo=
github.com/google/go-sev-guest v0.14.0/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760=
github.com/google/go-tdx-guest v0.3.2-0.20241009005452-097ee70d0843 h1:+MoPobRN9HrDhGyn6HnF5NYo4uMBKaiFqAtf/D/OB4A=
github.com/google/go-tdx-guest v0.3.2-0.20241009005452-097ee70d0843/go.mod h1:g/n8sKITIT9xRivBUbizo34DTsUm2nN2uU3A662h09g=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.4.5 h1:3fhthtyMDbIZFR5/0y1hvUoZ1Kf4i1eZ7C73R4Pvd+k=
github.com/google/go-tpm-tools v0.4.5/go.mod h1:ktjTNq8yZFD6TzdBFefUfen96rF3NpYwpSb2d8bc+Y8=
github.com/google/go-tpm-tools v0.4.7 h1:J3ycC8umYxM9A4eF73EofRZu4BxY0jjQnUnkhIBbvws=
github.com/google/go-tpm-tools v0.4.7/go.mod h1:gSyXTZHe3fgbzb6WEGd90QucmsnT1SRdlye82gH8QjQ=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ=
@ -484,10 +490,10 @@ github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c/go.mod h1:TIyPZe4Mgqvf
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
github.com/goreleaser/chglog v0.4.2 h1:afmbT1d7lX/q+GF8wv3a1Dofs2j/Y9YkiCpGemWR6mI=
@ -534,8 +540,8 @@ github.com/hectane/go-acl v0.0.0-20190604041725-da78bae5fc95/go.mod h1:QiyDdbZLa
github.com/hillu/go-ntdll v0.0.0-20220801201350-0d23f057ef1f h1:es0IoL1/OOoGYUuvRtSzbtG3STd7Fm5LIniUWsfzMHE=
github.com/hillu/go-ntdll v0.0.0-20220801201350-0d23f057ef1f/go.mod h1:cHjYsnAnSckPDx8/H01Y+owD1hf2adLA6VRiw4guEbA=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/igm/sockjs-go/v3 v3.0.2 h1:2m0k53w0DBiGozeQUIEPR6snZFmpFpYvVsGnfLPNXbE=
github.com/igm/sockjs-go/v3 v3.0.2/go.mod h1:UqchsOjeagIBFHvd+RZpLaVRbCwGilEC08EDHsD1jYE=
github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM=
@ -873,6 +879,8 @@ github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0o
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
@ -898,8 +906,8 @@ github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
go.einride.tech/aip v0.68.1 h1:16/AfSxcQISGN5z9C5lM+0mLYXihrHbQ1onvYTr93aQ=
go.einride.tech/aip v0.68.1/go.mod h1:XaFtaj4HuA3Zwk9xoBtTWgNubZ0ZZXv9BZJCkuKuWbg=
go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps=
go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ=
go.elastic.co/apm/module/apmgorilla/v2 v2.6.2 h1:/myBx0D/JiwTUjFkVFG3zXmDfGPfQjP/cg27qcBbdfU=
go.elastic.co/apm/module/apmgorilla/v2 v2.6.2/go.mod h1:uONZzSIh/cKjQ2rZmINR1VXVOJDq5eWOzKrCY+bu00w=
go.elastic.co/apm/module/apmhttp/v2 v2.7.1-0.20250407084155-22ab1be21948 h1:FS1GGVsZoIxezIGL2N3ExjQJzBA3Ne9hxp6HKvUhcRo=
@ -955,6 +963,8 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.step.sm/crypto v0.77.1 h1:4EEqfKdv0egQ1lqz2RhnU8Jv6QgXZfrgoxWMqJF9aDs=
go.step.sm/crypto v0.77.1/go.mod h1:U/SsmEm80mNnfD5WIkbhuW/B1eFp3fgFvdXyDLpU1AQ=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@ -969,6 +979,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -983,6 +994,7 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -1007,11 +1019,13 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -1020,6 +1034,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -1063,8 +1078,11 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@ -1072,6 +1090,9 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@ -1081,6 +1102,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
@ -1097,6 +1120,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@ -1105,8 +1129,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
@ -1114,12 +1138,12 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0=
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=

View file

@ -3,6 +3,8 @@ package mdmtest
import (
"bytes"
"context"
"crypto"
"crypto/ecdsa"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
@ -27,6 +29,7 @@ import (
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/dev_mode"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/acme/testhelpers"
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
@ -38,6 +41,7 @@ import (
"github.com/micromdm/plist"
"github.com/smallstep/pkcs7"
"github.com/smallstep/scep"
"golang.org/x/crypto/acme"
)
// TestAppleMDMClient simulates a macOS MDM client.
@ -48,6 +52,8 @@ type TestAppleMDMClient struct {
SerialNumber string
// Model is the model of the simulated device.
Model string
// OSVersion is the version of the operating system of the simulated device.
OSVersion string
// EnrollInfo holds the information necessary to enroll to an MDM server.
EnrollInfo AppleEnrollInfo
@ -116,11 +122,24 @@ type TestAppleMDMClient struct {
// SCEP enrollment process.
scepKey *rsa.PrivateKey
acmeCertCA *x509.Certificate
acmeCertCAKey *ecdsa.PrivateKey
acmeCert *x509.Certificate
acmeKey *ecdsa.PrivateKey
// ACME client used to enroll via ACME if enrollment profile is ACME based.
acmeClient *acme.Client
// legacyIDeviceEnrollRef is an optional enroll reference that will be added to the MDMURL after the
// client fetches the enrollment profile but prior to attempting SCEP enrollment. Note that this
// is not a full simulation of legacy enrollments (especially, as it related to IdP). Rather it
// is enough to test certain SCEP renewal scenarios for iOS/IPadOS devices
legacyIDeviceEnrollRef string
// skipParseEnrollProf, when set to true, will skip parsing the enrollment profile after
// fetching it. Instead, the raw profile bytes will still be stored in enrollProfBytes.
skipParseEnrollProf bool
}
// TestMDMAppleClientOption allows configuring a TestMDMClient.
@ -147,6 +166,20 @@ func WithOTAIdpUUID(idpUUID string) TestMDMAppleClientOption {
}
}
func WithSkipParseEnrollProf(skip bool) TestMDMAppleClientOption {
return func(c *TestAppleMDMClient) {
c.skipParseEnrollProf = skip
}
}
// Will set ACME CA certs, which is required if the device enrolls via the ACME flow
func WithACMECerts(certCA *x509.Certificate, certKey *ecdsa.PrivateKey) TestMDMAppleClientOption {
return func(c *TestAppleMDMClient) {
c.acmeCertCA = certCA
c.acmeCertCAKey = certKey
}
}
// Will add the specified reference as a query parameter to the MDMURL after the
// client fetches the enrollment profile but prior to attempting SCEP enrollment. Note that this
// is not a full simulation of legacy enrollments (especially, as it relates to IdP). Rather it
@ -168,6 +201,15 @@ type AppleEnrollInfo struct {
// AssignedManagedAppleID is the Assigned Managed Apple account for the device. Only used for
// account driven enrollment flows, so it will not always be available.
AssignedManagedAppleID string
// ACMEURL is the optional URL that will be used for ACME enrollment instead of the SCEP.
// Currently, this is only used for certain enrollment scenarios when
// config.mdm.apple_require_hardware_attestation is true.
ACMEURL string
// RawProfile contains the raw bytes of the enrollment profile. This is useful for tests that
// want to inspect the actual profile content. This field is populated regardless of the value
// of skipParseEnrollProf.
RawProfile []byte
}
// NewTestMDMClientAppleDesktopManual will create a simulated device that will fetch
@ -344,8 +386,15 @@ func (c *TestAppleMDMClient) enrollDevice(awaitingConfiguration bool) error {
c.EnrollInfo.MDMURL = parsedMDMURL.String()
}
if err := c.SCEPEnroll(); err != nil {
return fmt.Errorf("scep enroll: %w", err)
if c.acmeClient != nil {
// Do ACME enrollment
if err := c.ACMEEnroll(); err != nil {
return fmt.Errorf("ACME enroll: %w", err)
}
} else {
if err := c.SCEPEnroll(); err != nil {
return fmt.Errorf("scep enroll: %w", err)
}
}
if err := c.Authenticate(); err != nil {
return fmt.Errorf("authenticate: %w", err)
@ -419,8 +468,10 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDesktopURL() error {
func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURL() error {
di, err := EncodeDeviceInfo(fleet.MDMAppleMachineInfo{
Serial: c.SerialNumber,
UDID: c.UUID,
Serial: c.SerialNumber,
UDID: c.UUID,
Product: c.Model,
OSVersion: c.OSVersion,
})
if err != nil {
return fmt.Errorf("test client: encoding device info: %w", err)
@ -432,8 +483,10 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURL() error {
func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURLUsingPost() error {
buf, err := MachineInfoAsPKCS7(fleet.MDMAppleMachineInfo{
Serial: c.SerialNumber,
UDID: c.UUID,
Serial: c.SerialNumber,
UDID: c.UUID,
Product: c.Model,
OSVersion: c.OSVersion,
})
if err != nil {
return fmt.Errorf("test client: encoding device info: %w", err)
@ -619,10 +672,15 @@ func (c *TestAppleMDMClient) fetchOTAProfile(url string) error {
if err != nil {
return fmt.Errorf("verifying enrollment profile: %w", err)
}
if c.skipParseEnrollProf {
c.EnrollInfo.RawProfile = p7.Content
return nil
}
enrollInfo, err := ParseEnrollmentProfile(p7.Content)
if err != nil {
return fmt.Errorf("parse OTA SCEP profile: %w", err)
}
enrollInfo.RawProfile = p7.Content
c.EnrollInfo = *enrollInfo
return nil
}
@ -678,13 +736,28 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string, body []byte) (e
rawProfile = p7.Content
}
if c.skipParseEnrollProf {
c.EnrollInfo.RawProfile = rawProfile
return nil
}
enrollInfo, err := ParseEnrollmentProfile(rawProfile)
if err != nil {
return fmt.Errorf("parse enrollment profile: %w", err)
}
enrollInfo.RawProfile = rawProfile
c.EnrollInfo = *enrollInfo
if enrollInfo.ACMEURL != "" {
if c.acmeCertCA == nil || c.acmeCertCAKey == nil {
return errors.New("ACME enrollment requested but no cert/key provided")
}
c.acmeClient = &acme.Client{
Key: c.acmeCertCAKey,
DirectoryURL: enrollInfo.ACMEURL,
HTTPClient: fleethttp.NewClient(),
}
}
return nil
}
@ -830,6 +903,96 @@ func (c *TestAppleMDMClient) SCEPEnroll() error {
return nil
}
func (c *TestAppleMDMClient) ACMEEnroll() error {
if c.acmeClient == nil {
return errors.New("ACME URL not set in enrollment profile")
}
ctx := context.Background()
_, err := c.acmeClient.Register(ctx, &acme.Account{}, func(tosURL string) bool { return true })
if err != nil {
return fmt.Errorf("ACME register account: %w", err)
}
order, err := c.acmeClient.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "permanent-identifier", Value: c.SerialNumber}})
if err != nil {
return fmt.Errorf("ACME authorize order: %w", err)
}
if len(order.AuthzURLs) != 1 {
// We only create on authz for an order
return fmt.Errorf("expected 1 authz URL, got %d", len(order.AuthzURLs))
}
authz, err := c.acmeClient.GetAuthorization(ctx, order.AuthzURLs[0])
if err != nil {
return fmt.Errorf("ACME get authorization: %w", err)
}
if len(authz.Challenges) != 1 {
// We only create one challenge for an authz
return fmt.Errorf("expected 1 challenge, got %d", len(authz.Challenges))
}
if authz.Challenges[0].Type != "device-attest-01" {
return fmt.Errorf("expected challenge type device-attest-01, got %s", authz.Challenges[0].Type)
}
challenge := authz.Challenges[0]
leafCert, err := testhelpers.BuildAttestationLeafCert(c.acmeCertCA, c.acmeCertCAKey, c.SerialNumber, challenge.Token)
if err != nil {
return fmt.Errorf("build attestation leaf cert: %w", err)
}
payload, err := testhelpers.BuildAppleDeviceAttestationPayload(leafCert, c.acmeCertCA)
if err != nil {
return fmt.Errorf("build Apple device attestation payload: %w", err)
}
jsonPayload, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
challenge.Payload = jsonPayload
challenge, err = c.acmeClient.Accept(ctx, challenge)
if err != nil {
return fmt.Errorf("ACME accept challenge: %w", err)
}
if challenge.Status != "valid" {
return fmt.Errorf("challenge not valid after acceptance, status: %s", challenge.Status)
}
encoded, acmeKey, err := testhelpers.GenerateCSRDER(c.SerialNumber)
if err != nil {
return fmt.Errorf("generate CSR DER: %w", err)
}
// crypto/acme lib base64encodes inside the method, so we have to decode it again here
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return fmt.Errorf("decode CSR DER: %w", err)
}
der, _, err := c.acmeClient.CreateOrderCert(ctx, order.FinalizeURL, decoded, false)
if err != nil {
return fmt.Errorf("ACME create order cert and fetch cert: %w", err)
}
if len(der) != 1 {
// Since we don't bundle in CreateOrderCert, we only expect the leaf cert that we can sign requests with.
return fmt.Errorf("expected 1 certificate in ACME response, got %d", len(der))
}
acmeCert, err := x509.ParseCertificate(der[0])
if err != nil {
return fmt.Errorf("parse x509 ACME certificate: %w", err)
}
c.acmeCert = acmeCert
// We can reuse the same key we used for the CSR since it's the one that matches the cert
c.acmeKey = acmeKey
return nil
}
// Authenticate sends the Authenticate message to the MDM server (Check In protocol).
func (c *TestAppleMDMClient) Authenticate() error {
payload := map[string]any{
@ -1188,6 +1351,16 @@ func (c *TestAppleMDMClient) sendAndDecodeCommandResponse(payload map[string]any
return &p, nil
}
func (c *TestAppleMDMClient) getSignerCertAndKey() (*x509.Certificate, crypto.PrivateKey, error) {
if c.scepCert != nil && c.scepKey != nil {
return c.scepCert, c.scepKey, nil
}
if c.acmeCert != nil && c.acmeKey != nil {
return c.acmeCert, c.acmeKey, nil
}
return nil, nil, errors.New("no signer certificate and key available")
}
func (c *TestAppleMDMClient) request(contentType string, payload map[string]any) (*http.Response, error) {
body, err := plist.Marshal(payload)
if err != nil {
@ -1198,7 +1371,11 @@ func (c *TestAppleMDMClient) request(contentType string, payload map[string]any)
if err != nil {
return nil, fmt.Errorf("create signed data: %w", err)
}
err = signedData.AddSigner(c.scepCert, c.scepKey, pkcs7.SignerInfoConfig{})
cert, key, err := c.getSignerCertAndKey()
if err != nil {
return nil, fmt.Errorf("get signer certificate and key: %w", err)
}
err = signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{})
if err != nil {
return nil, fmt.Errorf("add signer: %w", err)
}
@ -1237,21 +1414,12 @@ func (c *TestAppleMDMClient) request(contentType string, payload map[string]any)
// ParseEnrollmentProfile parses the enrollment profile and returns the parsed information as EnrollInfo.
func ParseEnrollmentProfile(mobileConfig []byte) (*AppleEnrollInfo, error) {
var enrollmentProfile struct {
PayloadContent []map[string]interface{} `plist:"PayloadContent"`
PayloadContent []map[string]any `plist:"PayloadContent"`
}
if err := plist.Unmarshal(mobileConfig, &enrollmentProfile); err != nil {
return nil, fmt.Errorf("unmarshal enrollment profile: %w", err)
}
payloadContent := enrollmentProfile.PayloadContent[0]["PayloadContent"].(map[string]interface{})
scepChallenge, ok := payloadContent["Challenge"].(string)
if !ok || scepChallenge == "" {
return nil, errors.New("SCEP Challenge field not found")
}
scepURL, ok := payloadContent["URL"].(string)
if !ok || scepURL == "" {
return nil, errors.New("SCEP URL field not found")
}
mdmURL, ok := enrollmentProfile.PayloadContent[1]["ServerURL"].(string)
if !ok || mdmURL == "" {
return nil, errors.New("MDM ServerURL field not found")
@ -1269,12 +1437,77 @@ func ParseEnrollmentProfile(mobileConfig []byte) (*AppleEnrollInfo, error) {
assignedManagedAppleID = assignedManagedAppleIDVal.(string)
}
return &AppleEnrollInfo{
SCEPChallenge: scepChallenge,
SCEPURL: scepURL,
enrollInfo := &AppleEnrollInfo{
MDMURL: mdmURL,
AssignedManagedAppleID: assignedManagedAppleID,
}, nil
}
var err error
payloadContent, ok := enrollmentProfile.PayloadContent[0]["PayloadContent"].(map[string]any)
if ok {
enrollInfo, err = parseSCEPEnrollmentPayload(*enrollInfo, payloadContent)
if err != nil {
return nil, err
}
} else {
// Check for ACME
_, ok := enrollmentProfile.PayloadContent[0]["DirectoryURL"].(string)
_, ok2 := enrollmentProfile.PayloadContent[0]["HardwareBound"].(bool)
_, ok3 := enrollmentProfile.PayloadContent[0]["Attest"].(bool)
if !ok || !ok2 || !ok3 {
// One of ACME fields are not present (we don't care about the value, but they need to be present)
return nil, errors.New("not a valid ACME or SCEP enrollment profile")
}
enrollInfo, err = parseACMEEnrollmentPayload(*enrollInfo, enrollmentProfile.PayloadContent[0])
if err != nil {
return nil, err
}
}
return enrollInfo, nil
}
func parseSCEPEnrollmentPayload(enrollInfo AppleEnrollInfo, payloadContent map[string]any) (*AppleEnrollInfo, error) {
scepChallenge, ok := payloadContent["Challenge"].(string)
if !ok || scepChallenge == "" {
return nil, errors.New("SCEP Challenge field not found")
}
scepURL, ok := payloadContent["URL"].(string)
if !ok || scepURL == "" {
return nil, errors.New("SCEP URL field not found")
}
enrollInfo.SCEPChallenge = scepChallenge
enrollInfo.SCEPURL = scepURL
return &enrollInfo, nil
}
func parseACMEEnrollmentPayload(enrollInfo AppleEnrollInfo, payloadContent map[string]any) (*AppleEnrollInfo, error) {
directoryURL, ok := payloadContent["DirectoryURL"].(string)
if !ok || directoryURL == "" {
return nil, errors.New("ACME DirectoryURL field not found")
}
// TODO CLEAN UP
/*
PayloadIdentifier: BCA53F9D-5DD2-494D-98D3-0D0F20FF6BA1
PayloadUUID: BCA53F9D-5DD2-494D-98D3-0D0F20FF6BA1
ClientIdentifier: 1c0a81b9-1f30-4393-aa5d-0d4064640233
DirectoryURL: http://127.0.0.1:63768/api/mdm/acme/0639af7d-7009-4ac2-9b73-839984323b68/directory
PayloadDisplayName: Fleet Identity ACME
PayloadType: com.apple.security.acme
HardwareBound: true
KeySize: 384
PayloadVersion: 1
Subject: [[[CN %SerialNumber%]]]
KeyType: ECSECPrimeRandom
Attest: true
*/
// TODO: Directory URL or just base URL with identifier
enrollInfo.ACMEURL = directoryURL
return &enrollInfo, nil
}
// numbers plus capital letters without I, L, O for readability

View file

@ -184,6 +184,8 @@ type GitOpsControls struct {
AndroidEnabledAndConfigured any `json:"android_enabled_and_configured"`
AndroidSettings any `json:"android_settings"`
AppleRequireHardwareAttestation any `json:"apple_require_hardware_attestation"`
EnableDiskEncryption any `json:"enable_disk_encryption"`
EnableRecoveryLockPassword any `json:"enable_recovery_lock_password"`
RequireBitLockerPIN any `json:"windows_require_bitlocker_pin,omitempty"`

View file

@ -328,6 +328,8 @@ func TestValidGitOpsYaml(t *testing.T) {
assert.True(t, ok, "windows_entra_tenant_ids not found")
_, ok = gitops.Controls.WindowsUpdates.(map[string]interface{})
assert.True(t, ok, "windows_updates not found")
_, ok = gitops.Controls.AppleRequireHardwareAttestation.(bool)
assert.True(t, ok, "apple_require_hardware_attestation not found")
assert.Equal(t, "fleet_secret", gitops.FleetSecrets["FLEET_SECRET_FLEET_SECRET_"])
assert.Equal(t, "secret_name", gitops.FleetSecrets["FLEET_SECRET_NAME"])
assert.Equal(t, "10", gitops.FleetSecrets["FLEET_SECRET_LENGTH"])

View file

@ -29,6 +29,7 @@ windows_enabled_and_configured: true
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: []
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null

View file

@ -30,6 +30,7 @@ windows_enabled_and_configured: true
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: []
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null

View file

@ -31,6 +31,7 @@ controls: # Controls added to "No team"
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: []
apple_require_hardware_attestation: false
windows_updates:
deadline_days: null
grace_period_days: null

View file

@ -57,6 +57,7 @@ controls:
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: []
apple_require_hardware_attestation: false
software:
app_store_apps:
- app_store_id: "123456"

View file

@ -65,6 +65,7 @@ controls:
windows_migration_enabled: false
enable_turn_on_windows_mdm_manually: false
windows_entra_tenant_ids: []
apple_require_hardware_attestation: false
labels:
- name: a
description: A cool global label

View file

@ -0,0 +1,57 @@
// Package acmeacl provides the anti-corruption layer between the ACME
// bounded context and legacy Fleet code.
//
// This package is the ONLY place that imports both ACME types and fleet types.
// It translates between them, allowing the ACME context to remain decoupled
// from legacy code.
package acmeacl
import (
"context"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/mdm/acme"
)
// FleetDatastoreAdapter adapts fleet.Datastore to the narrow
// acme.DataProviders interface that the ACME bounded context requires.
type FleetDatastoreAdapter struct {
ds fleet.Datastore
signer acme.CSRSigner
}
// NewFleetDatastoreAdapter creates a new adapter for the Fleet datastore.
func NewFleetDatastoreAdapter(ds fleet.Datastore, signer acme.CSRSigner) *FleetDatastoreAdapter {
return &FleetDatastoreAdapter{ds: ds, signer: signer}
}
// Ensure FleetDatastoreAdapter implements acme.DataProviders
var _ acme.DataProviders = (*FleetDatastoreAdapter)(nil)
func (a *FleetDatastoreAdapter) ServerURL(ctx context.Context) (string, error) {
appCfg, err := a.ds.AppConfig(ctx)
if err != nil {
return "", err
}
return appCfg.MDMUrl(), nil
}
func (a *FleetDatastoreAdapter) GetCACertificatePEM(ctx context.Context) ([]byte, error) {
assets, err := a.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert}, nil)
if err != nil {
return nil, err
}
return assets[fleet.MDMAssetCACert].Value, nil
}
func (a *FleetDatastoreAdapter) CSRSigner(_ context.Context) (acme.CSRSigner, error) {
return a.signer, nil
}
func (a *FleetDatastoreAdapter) IsDEPEnrolled(ctx context.Context, serial string) (bool, error) {
assignments, err := a.ds.GetHostDEPAssignmentsBySerial(ctx, serial)
if err != nil {
return false, err
}
return len(assignments) > 0, nil
}

View file

@ -1740,13 +1740,14 @@ func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts [
}
stmt := `
INSERT INTO host_dep_assignments (host_id, abm_token_id, mdm_migration_deadline)
INSERT INTO host_dep_assignments (host_id, abm_token_id, mdm_migration_deadline, hardware_serial)
VALUES %s
ON DUPLICATE KEY UPDATE
added_at = CURRENT_TIMESTAMP,
deleted_at = NULL,
abm_token_id = VALUES(abm_token_id),
mdm_migration_deadline = VALUES(mdm_migration_deadline)`
added_at = CURRENT_TIMESTAMP,
deleted_at = NULL,
abm_token_id = VALUES(abm_token_id),
mdm_migration_deadline = VALUES(mdm_migration_deadline),
hardware_serial = VALUES(hardware_serial)`
args := []interface{}{}
values := []string{}
@ -1755,8 +1756,8 @@ func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts [
if d, ok := migrationDeadlinesByHostID[host.ID]; ok {
deadline = &d
}
args = append(args, host.ID, abmTokenID, deadline)
values = append(values, "(?, ?, ?)")
args = append(args, host.ID, abmTokenID, deadline, host.HardwareSerial)
values = append(values, "(?, ?, ?, ?)")
}
_, err := tx.ExecContext(ctx, fmt.Sprintf(stmt, strings.Join(values, ",")), args...)
@ -2038,6 +2039,18 @@ func (ds *Datastore) GetHostDEPAssignment(ctx context.Context, hostID uint) (*fl
return &res, nil
}
func (ds *Datastore) GetHostDEPAssignmentsBySerial(ctx context.Context, serial string) ([]*fleet.HostDEPAssignment, error) {
var res []*fleet.HostDEPAssignment
err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, `
SELECT host_id, added_at, deleted_at, abm_token_id, mdm_migration_deadline, mdm_migration_completed
FROM host_dep_assignments hdep
WHERE hdep.hardware_serial = ? AND hdep.deleted_at IS NULL`, serial)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "getting host dep assignments by serial")
}
return res, nil
}
func (ds *Datastore) SetHostMDMMigrationCompleted(ctx context.Context, hostID uint) error {
_, err := ds.writer(ctx).ExecContext(ctx, `
UPDATE host_dep_assignments
@ -4548,7 +4561,7 @@ func (ds *Datastore) ListMDMAppleDEPSerialsInTeam(ctx context.Context, teamID *u
stmt := fmt.Sprintf(`
SELECT
hardware_serial
h.hardware_serial
FROM
hosts h
JOIN host_dep_assignments hda ON hda.host_id = h.id
@ -4573,7 +4586,7 @@ func (ds *Datastore) ListMDMAppleDEPSerialsInHostIDs(ctx context.Context, hostID
stmt := `
SELECT
hardware_serial
h.hardware_serial
FROM
hosts h
JOIN host_dep_assignments hda ON hda.host_id = h.id
@ -4749,7 +4762,7 @@ SET
response_updated_at = CURRENT_TIMESTAMP,
retry_job_id = 0
WHERE
hardware_serial IN (?)
hosts.hardware_serial IN (?)
`, setABMTokenID)
var args []interface{}
var err error
@ -4848,7 +4861,7 @@ func (ds *Datastore) GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (m
const stmt = `
SELECT
COALESCE(team_id, 0) AS team_id,
hardware_serial
h.hardware_serial
FROM
host_dep_assignments
JOIN hosts h ON h.id = host_id
@ -4893,7 +4906,7 @@ JOIN
SET
retry_job_id = ?
WHERE
hardware_serial IN (?)`
hosts.hardware_serial IN (?)`
stmt, args, err := sqlx.In(stmt, jobID, serials)
if err != nil {
@ -6334,16 +6347,16 @@ func (ds *Datastore) ListIOSAndIPadOSToRefetch(ctx context.Context, interval tim
err error,
) {
hostsStmt := `
SELECT
h.id as host_id,
h.uuid as uuid,
SELECT
h.id as host_id,
h.uuid as uuid,
hmdm.installed_from_dep,
JSON_ARRAYAGG(hmc.command_type) as commands_already_sent
FROM hosts h
INNER JOIN host_mdm hmdm ON hmdm.host_id = h.id
INNER JOIN nano_enrollments ne ON ne.id = h.uuid
LEFT JOIN host_mdm_commands hmc ON hmc.host_id = h.id AND hmc.command_type IN (?)
WHERE
WHERE
(h.platform = 'ios' OR h.platform = 'ipados')
AND TRIM(h.uuid) != ''
AND TIMESTAMPDIFF(SECOND, h.detail_updated_at, NOW()) > ?
@ -6980,7 +6993,7 @@ FROM
JOIN
host_dep_assignments hdep ON h.id = host_id
WHERE
hardware_serial = ? AND deleted_at IS NULL
h.hardware_serial = ? AND deleted_at IS NULL
LIMIT 1`
var dest struct {
@ -7173,10 +7186,11 @@ LIMIT ?
return res, nil
}
func (ds *Datastore) GetNanoMDMEnrollmentTimes(ctx context.Context, hostUUID string) (*time.Time, *time.Time, error) {
func (ds *Datastore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
res := []struct {
LastMDMEnrollmentTime *time.Time `db:"authenticate_at"`
LastMDMSeenTime *time.Time `db:"last_seen_at"`
HardwareAttested bool `db:"hardware_attested"`
}{}
// We are specifically only looking at the singular device enrollment row and not the
// potentially many user enrollment rows that will exist for a given device. The device
@ -7184,19 +7198,19 @@ func (ds *Datastore) GetNanoMDMEnrollmentTimes(ctx context.Context, hostUUID str
// those same lines authenticate_at gets updated only at the authenticate step during the
// enroll process and as such is a good indicator of the last enrollment or reenrollment.
query := `
SELECT nd.authenticate_at, ne.last_seen_at
SELECT nd.authenticate_at, ne.last_seen_at, ne.hardware_attested
FROM nano_devices nd
INNER JOIN nano_enrollments ne ON ne.id = nd.id
WHERE ne.type IN ('Device', 'User Enrollment (Device)') AND nd.id = ?`
err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, query, hostUUID)
if err == sql.ErrNoRows || len(res) == 0 {
return nil, nil, nil
return nil, nil, false, nil
}
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "get mdm enrollment times")
return nil, nil, false, ctxerr.Wrap(ctx, err, "get mdm enrollment times")
}
return res[0].LastMDMEnrollmentTime, res[0].LastMDMSeenTime, nil
return res[0].LastMDMEnrollmentTime, res[0].LastMDMSeenTime, res[0].HardwareAttested, nil
}
func (ds *Datastore) AssociateHostMDMIdPAccount(ctx context.Context, hostUUID, idpAcctUUID string) error {

View file

@ -102,7 +102,7 @@ func TestMDMApple(t *testing.T) {
{"AggregateMacOSSettingsAllPlatforms", testAggregateMacOSSettingsAllPlatforms},
{"GetMDMAppleEnrolledDeviceDeletedFromFleet", testGetMDMAppleEnrolledDeviceDeletedFromFleet},
{"SetMDMAppleProfilesWithVariables", testSetMDMAppleProfilesWithVariables},
{"GetNanoMDMEnrollmentTimes", testGetNanoMDMEnrollmentTimes},
{"GetNanoMDMEnrollmentDetails", testGetNanoMDMEnrollmentDetails},
{"GetNanoMDMUserEnrollment", testGetNanoMDMUserEnrollment},
{"TestDeleteMDMAppleDeclarationWithPendingInstalls", testDeleteMDMAppleDeclarationWithPendingInstalls},
{"TestUpdateNanoMDMUserEnrollmentUsername", testUpdateNanoMDMUserEnrollmentUsername},
@ -5420,10 +5420,10 @@ func testMDMAppleResetEnrollment(t *testing.T, ds *Datastore) {
require.NoError(t, err)
// add a record of the host DEP assignment
_, err = ds.writer(ctx).Exec(`
INSERT INTO host_dep_assignments (host_id)
VALUES (?)
INSERT INTO host_dep_assignments (host_id, hardware_serial)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE added_at = CURRENT_TIMESTAMP, deleted_at = NULL
`, host.ID)
`, host.ID, host.HardwareSerial)
require.NoError(t, err)
cmd, err := ds.GetHostBootstrapPackageCommand(ctx, host.UUID)
require.NoError(t, err)
@ -5502,7 +5502,7 @@ func testMDMAppleDeleteHostDEPAssignments(t *testing.T, ds *Datastore) {
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
return sqlx.SelectContext(
ctx, q, &got,
`SELECT hardware_serial FROM hosts h
`SELECT h.hardware_serial FROM hosts h
JOIN host_dep_assignments hda ON hda.host_id = h.id
WHERE hda.deleted_at IS NULL`,
)
@ -9090,7 +9090,7 @@ func testAppleMDMSetBatchAsyncLastSeenAt(t *testing.T, ds *Datastore) {
require.True(t, ts2b.After(ts2))
}
func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
func testGetNanoMDMEnrollmentDetails(t *testing.T, ds *Datastore) {
ctx := t.Context()
host, err := ds.NewHost(ctx, &fleet.Host{
Hostname: "test-host1-name",
@ -9102,7 +9102,7 @@ func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
lastMDMEnrolledAt, lastMDMSeenAt, err := ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
lastMDMEnrolledAt, lastMDMSeenAt, _, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.Nil(t, lastMDMEnrolledAt)
require.Nil(t, lastMDMSeenAt)
@ -9111,7 +9111,7 @@ func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
// returned yet
nanoEnroll(t, ds, host, true)
lastMDMEnrolledAt, lastMDMSeenAt, err = ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.NotNil(t, lastMDMEnrolledAt) // defaults to current time on creation
require.NotNil(t, lastMDMSeenAt) // defaults to time 0 value
@ -9128,7 +9128,7 @@ func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
require.NoError(t, err)
nanoEnrollUserDevice(t, ds, byodHost)
lastMDMEnrolledAt, lastMDMSeenAt, err = ds.GetNanoMDMEnrollmentTimes(ctx, byodHost.UUID)
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID)
require.NoError(t, err)
require.NotNil(t, lastMDMEnrolledAt) // defaults to current time on creation
require.NotNil(t, lastMDMSeenAt) // defaults to time 0 value
@ -9164,14 +9164,14 @@ func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
return nil
})
lastMDMEnrolledAt, lastMDMSeenAt, err = ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.NotNil(t, lastMDMEnrolledAt)
assert.Equal(t, authenticateTime, *lastMDMEnrolledAt)
require.NotNil(t, lastMDMSeenAt)
assert.Equal(t, deviceEnrollTime, *lastMDMSeenAt)
lastMDMEnrolledAt, lastMDMSeenAt, err = ds.GetNanoMDMEnrollmentTimes(ctx, byodHost.UUID)
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID)
require.NoError(t, err)
require.NotNil(t, lastMDMEnrolledAt)
assert.Equal(t, byodDeviceAuthenticateTime, *lastMDMEnrolledAt)
@ -9202,7 +9202,7 @@ func testGetNanoMDMUserEnrollment(t *testing.T, ds *Datastore) {
})
require.NoError(t, err)
lastMDMEnrolledAt, lastMDMSeenAt, err := ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
lastMDMEnrolledAt, lastMDMSeenAt, _, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
require.NoError(t, err)
require.Nil(t, lastMDMEnrolledAt)
require.Nil(t, lastMDMSeenAt)
@ -9924,7 +9924,7 @@ func testGetDEPAssignProfileExpiredCooldowns(t *testing.T, ds *Datastore) {
host := newTestHostWithPlatform(t, ds, "macos", "macos", nil)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO host_dep_assignments (host_id, profile_uuid) VALUES (?, ?)`, host.ID, uuid.NewString())
_, err := q.ExecContext(ctx, `INSERT INTO host_dep_assignments (host_id, profile_uuid, hardware_serial) VALUES (?, ?, ?)`, host.ID, uuid.NewString(), host.HardwareSerial)
return err
})
@ -9954,7 +9954,7 @@ func testGetDEPAssignProfileExpiredCooldowns(t *testing.T, ds *Datastore) {
for i := range 200 {
h := newTestHostWithPlatform(t, ds, fmt.Sprintf("host-%d", i), "macos", nil)
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
_, err := q.ExecContext(ctx, `INSERT INTO host_dep_assignments (host_id, profile_uuid, assign_profile_response, response_updated_at, retry_job_id) VALUES (?, ?, ?, DATE_SUB(NOW(), INTERVAL ? SECOND), 0)`, h.ID, uuid.NewString(), fleet.DEPAssignProfileResponseFailed, depFailedCooldownPeriod.Seconds()+10)
_, err := q.ExecContext(ctx, `INSERT INTO host_dep_assignments (host_id, profile_uuid, assign_profile_response, response_updated_at, retry_job_id, hardware_serial) VALUES (?, ?, ?, DATE_SUB(NOW(), INTERVAL ? SECOND), 0, ?)`, h.ID, uuid.NewString(), fleet.DEPAssignProfileResponseFailed, depFailedCooldownPeriod.Seconds()+10, h.HardwareSerial)
return err
})
}

View file

@ -70,14 +70,14 @@ func (ds *Datastore) GetHostIdentityCertByName(ctx context.Context, name string)
// certificate-based authentication on the My Device page.
//
// This query uses the nano_cert_auth_associations table which maps device IDs to
// certificate hashes. The serial number lookup in scep_certificates provides
// certificate hashes. The serial number lookup in identity_certificates provides
// the raw certificate data, but we need the nanomdm association to get the device UUID.
func (ds *Datastore) GetMDMSCEPCertBySerial(ctx context.Context, serialNumber uint64) (deviceUUID string, err error) {
// First get the certificate by serial
var certPEM string
err = sqlx.GetContext(ctx, ds.reader(ctx), &certPEM, `
SELECT certificate_pem
FROM scep_certificates
FROM identity_certificates
WHERE serial = ?
AND not_valid_after > NOW()
AND revoked = 0`, serialNumber)

View file

@ -37,7 +37,7 @@ var (
)
var (
hostSearchColumns = []string{"hostname", "computer_name", "uuid", "hardware_serial", "primary_ip"}
hostSearchColumns = []string{"hostname", "computer_name", "uuid", "h.hardware_serial", "primary_ip"}
wildCardableHostSearchColumns = []string{"hostname", "computer_name"}
)
@ -6085,7 +6085,7 @@ func (ds *Datastore) GetMatchingHostSerialsMarkedDeleted(ctx context.Context, se
stmt := `
SELECT
hardware_serial
h.hardware_serial
FROM
hosts h
JOIN host_dep_assignments hdep ON hdep.host_id = h.id

View file

@ -9011,7 +9011,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
_, err = ds.writer(context.Background()).Exec(`INSERT INTO host_software_installed_paths (host_id, software_id, installed_path) VALUES (?, ?, ?)`, host.ID, 1, "some_path")
require.NoError(t, err)
_, err = ds.writer(context.Background()).Exec(`INSERT INTO host_dep_assignments (host_id) VALUES (?)`, host.ID)
_, err = ds.writer(context.Background()).Exec(`INSERT INTO host_dep_assignments (host_id, hardware_serial) VALUES (?, ?)`, host.ID, host.HardwareSerial)
require.NoError(t, err)
_, err = ds.writer(context.Background()).Exec(`

View file

@ -1763,6 +1763,42 @@ LIMIT ?`, expiryDays, limit)
return uuids, nil
}
func (ds *Datastore) GetDeviceInfoForACMERenewal(ctx context.Context, hostUUIDs []string) ([]fleet.DeviceInfoForACMERenewal, error) {
if len(hostUUIDs) == 0 {
return []fleet.DeviceInfoForACMERenewal{}, nil
}
// TODO(mna): anyone knows what those TODOs (from Sarah's PRs) were for?
// TODO: refactor this to use hw model from host_dep_assignments once we have that fully in place
// TODO: confirm we can rely on host_operating_system and operating_systems tables for accurate OS version information
stmt := `
SELECT
h.uuid AS host_uuid,
h.hardware_serial AS hardware_serial,
h.hardware_model AS hardware_model,
os.version AS os_version
FROM
hosts h
JOIN host_dep_assignments hda ON hda.host_id = h.id
JOIN host_operating_system hos ON hos.host_id = h.id
JOIN operating_systems os ON os.id = hos.os_id
WHERE
h.uuid IN(?)
AND hda.deleted_at IS NULL
AND os.name = 'macOS'`
stmt, args, err := sqlx.In(stmt, hostUUIDs)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In query for ACME hardware attestation")
}
var dest []fleet.DeviceInfoForACMERenewal
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &dest, stmt, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host details for ACME hardware attestation")
}
return dest, nil
}
func (ds *Datastore) SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
if len(assocs) == 0 {
return nil

View file

@ -0,0 +1,129 @@
package tables
import (
"database/sql"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20260401153000, Down_20260401153000)
}
func Up_20260401153000(tx *sql.Tx) error {
_, err := tx.Exec(`
ALTER TABLE nano_enrollments
ADD COLUMN hardware_attested TINYINT(1) NOT NULL DEFAULT 0
`)
if err != nil {
return errors.Wrap(err, "add nano_enrollments hardware_attested column")
}
_, err = tx.Exec(`
ALTER TABLE scep_serials RENAME TO identity_serials
`)
if err != nil {
return errors.Wrap(err, "rename scep_serials to identity_serials")
}
_, err = tx.Exec(`
ALTER TABLE scep_certificates RENAME TO identity_certificates
`)
if err != nil {
return errors.Wrap(err, "rename scep_certificates to identity_certificates")
}
_, err = tx.Exec(`
CREATE TABLE acme_enrollments (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
path_identifier VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
host_identifier VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
not_valid_after DATETIME DEFAULT NULL,
revoked TINYINT(1) NOT NULL DEFAULT '0',
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY idx_path_identifier (path_identifier)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`)
if err != nil {
return errors.Wrap(err, "create acme_enrollments table")
}
_, err = tx.Exec(`
CREATE TABLE acme_accounts (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
acme_enrollment_id INT UNSIGNED NOT NULL,
json_web_key json NOT NULL,
json_web_key_thumbprint VARCHAR(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
revoked TINYINT(1) NOT NULL DEFAULT '0',
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (acme_enrollment_id) REFERENCES acme_enrollments(id) ON DELETE CASCADE ON UPDATE CASCADE,
UNIQUE KEY idx_enrollment_id_thumbprint (acme_enrollment_id, json_web_key_thumbprint)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`)
if err != nil {
return errors.Wrap(err, "create acme_accounts table")
}
_, err = tx.Exec(`
CREATE TABLE acme_orders (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
acme_account_id INT UNSIGNED NOT NULL,
finalized TINYINT(1) NOT NULL DEFAULT '0',
certificate_signing_request TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
identifiers json NOT NULL,
status enum('pending', 'ready', 'processing', 'valid', 'invalid') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending',
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),
FOREIGN KEY (acme_account_id) REFERENCES acme_accounts(id) ON DELETE CASCADE ON UPDATE CASCADE,
UNIQUE KEY idx_issued_certificate_serial (issued_certificate_serial)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`)
if err != nil {
return errors.Wrap(err, "create acme_orders table")
}
_, err = tx.Exec(`
CREATE TABLE acme_authorizations (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
identifier_type varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
identifier_value varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
acme_order_id INT UNSIGNED NOT NULL,
status enum('pending', 'valid', 'invalid', 'deactivated', 'expired', 'revoked') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending',
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (acme_order_id) REFERENCES acme_orders(id) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`)
if err != nil {
return errors.Wrap(err, "create acme_authorizations table")
}
_, err = tx.Exec(`
CREATE TABLE acme_challenges (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
challenge_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
token varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
acme_authorization_id INT UNSIGNED NOT NULL,
status enum('pending', 'valid', 'invalid', 'processing') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending',
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (acme_authorization_id) REFERENCES acme_authorizations(id) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`)
if err != nil {
return errors.Wrap(err, "create acme_challenges table")
}
return nil
}
func Down_20260401153000(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,29 @@
package tables
import (
"database/sql"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20260401153001, Down_20260401153001)
}
func Up_20260401153001(tx *sql.Tx) error {
err := updateAppConfigJSON(tx, func(config *fleet.AppConfig) error {
if config != nil {
config.MDM.AppleRequireHardwareAttestation = false
}
return nil
})
if err != nil {
return errors.Wrap(err, "set AppleRequireHardwareAttestation in AppConfig")
}
return nil
}
func Down_20260401153001(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,38 @@
package tables
import (
"database/sql"
"github.com/pkg/errors"
)
func init() {
MigrationClient.AddMigration(Up_20260401153503, Down_20260401153503)
}
func Up_20260401153503(tx *sql.Tx) error {
_, err := tx.Exec(`
ALTER TABLE host_dep_assignments
ADD COLUMN hardware_serial varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
ADD INDEX idx_hdep_hardware_serial (hardware_serial)
`)
if err != nil {
return errors.Wrap(err, "add host_dep_assignments.hardware_serial column")
}
_, err = tx.Exec(`
UPDATE host_dep_assignments hda
JOIN hosts h ON h.id = hda.host_id
SET hda.hardware_serial = h.hardware_serial
WHERE hda.deleted_at IS NULL
`)
if err != nil {
return errors.Wrap(err, "backfill host_dep_assignments.hardware_serial column")
}
return nil
}
func Down_20260401153503(tx *sql.Tx) error {
return nil
}

View file

@ -0,0 +1,130 @@
package tables
import (
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
func TestUp_20260401153503_SomeAssignments(t *testing.T) {
db := applyUpToPrev(t)
// create a dozen hosts each for macOS, Windows and Linux
macIDs, _, _, _ := insertHosts(t, db, 12, 12, 12)
require.Len(t, macIDs, 12)
// load the serials for the mac hosts
type host struct {
ID uint `db:"id"`
HardwareSerial string `db:"hardware_serial"`
}
var hosts []host
stmt, args, err := sqlx.In(`SELECT id, hardware_serial FROM hosts WHERE id IN (?)`, macIDs)
require.NoError(t, err)
err = db.Select(&hosts, stmt, args...)
require.NoError(t, err)
require.Len(t, hosts, 12)
idToSerial := make(map[uint]string)
for _, h := range hosts {
idToSerial[h.ID] = h.HardwareSerial
}
// create DEP assignments for a few mac hosts
for _, id := range macIDs[:3] {
_, err := db.Exec(`INSERT INTO host_dep_assignments (host_id) VALUES (?)`, id)
require.NoError(t, err)
}
// make macIDs[2] a deleted assignment
_, err = db.Exec(`UPDATE host_dep_assignments SET deleted_at = NOW() WHERE host_id = ?`, macIDs[2]) //nolint:nilaway
require.NoError(t, err)
// Apply current migration.
applyNext(t, db)
// load the assignments and verify that it has the expected hardware serials for non-deleted assignments
var assignments []struct {
HostID uint `db:"host_id"`
HardwareSerial string `db:"hardware_serial"`
DeletedAt *time.Time `db:"deleted_at"`
}
err = db.Select(&assignments, `SELECT host_id, hardware_serial, deleted_at FROM host_dep_assignments`)
require.NoError(t, err)
require.Len(t, assignments, 3)
for _, a := range assignments {
switch a.HostID {
case macIDs[0], macIDs[1]:
require.Nil(t, a.DeletedAt)
require.Equal(t, idToSerial[a.HostID], a.HardwareSerial)
case macIDs[2]:
require.Empty(t, a.HardwareSerial)
require.NotNil(t, a.DeletedAt)
default:
t.Fatalf("unexpected host_id %d in host_dep_assignments", a.HostID)
}
}
}
func TestUp_20260401153503_NoAssignment(t *testing.T) {
db := applyUpToPrev(t)
// Apply current migration.
applyNext(t, db)
var count int
err := db.Get(&count, `SELECT COUNT(*) FROM host_dep_assignments`)
require.NoError(t, err)
require.Equal(t, 0, count)
}
func TestUp_20260401153503_ManyAssignments(t *testing.T) {
db := applyUpToPrev(t)
// create a thousand macOS hosts and a few other
macIDs, _, _, _ := insertHosts(t, db, 1000, 10, 10)
require.Len(t, macIDs, 1000)
// load the serials for the mac hosts
type host struct {
ID uint `db:"id"`
HardwareSerial string `db:"hardware_serial"`
}
var hosts []host
stmt, args, err := sqlx.In(`SELECT id, hardware_serial FROM hosts WHERE id IN (?)`, macIDs)
require.NoError(t, err)
err = db.Select(&hosts, stmt, args...)
require.NoError(t, err)
require.Len(t, hosts, len(macIDs))
idToSerial := make(map[uint]string)
for _, h := range hosts {
idToSerial[h.ID] = h.HardwareSerial
}
// create DEP assignments for all mac hosts
for _, id := range macIDs {
_, err := db.Exec(`INSERT INTO host_dep_assignments (host_id) VALUES (?)`, id)
require.NoError(t, err)
}
// Apply current migration.
applyNext(t, db)
// load the assignments and verify that it has the expected hardware serials for non-deleted assignments
var assignments []struct {
HostID uint `db:"host_id"`
HardwareSerial string `db:"hardware_serial"`
}
err = db.Select(&assignments, `SELECT host_id, hardware_serial FROM host_dep_assignments`)
require.NoError(t, err)
require.Len(t, assignments, len(macIDs))
for _, a := range assignments {
require.Equal(t, idToSerial[a.HostID], a.HardwareSerial)
}
}

View file

@ -54,7 +54,7 @@ func (d *SCEPDepot) CA(_ []byte) ([]*x509.Certificate, *rsa.PrivateKey, error) {
// Serial allocates and returns a new (increasing) serial number.
func (d *SCEPDepot) Serial() (*big.Int, error) {
result, err := d.db.Exec(`INSERT INTO scep_serials () VALUES ();`)
result, err := d.db.Exec(`INSERT INTO identity_serials () VALUES ();`)
if err != nil {
return nil, err
}
@ -72,7 +72,7 @@ func (d *SCEPDepot) Serial() (*big.Int, error) {
// - revokeOldCertificate specifies whether to revoke the old certificate once renewed.
func (d *SCEPDepot) HasCN(cn string, allowTime int, cert *x509.Certificate, revokeOldCertificate bool) (bool, error) {
var ct int
row := d.db.QueryRow(`SELECT COUNT(*) FROM scep_certificates WHERE name = ?`, cn)
row := d.db.QueryRow(`SELECT COUNT(*) FROM identity_certificates WHERE name = ?`, cn)
if err := row.Scan(&ct); err != nil {
return false, err
}
@ -92,7 +92,7 @@ func (d *SCEPDepot) Put(name string, crt *x509.Certificate) error {
}
certPEM := certificate.EncodeCertPEM(crt)
_, err := d.db.Exec(`
INSERT INTO scep_certificates
INSERT INTO identity_certificates
(serial, name, not_valid_before, not_valid_after, certificate_pem)
VALUES
(?, ?, ?, ?, ?)`,

File diff suppressed because one or more lines are too long

View file

@ -133,11 +133,16 @@ func ReadOnlyConn(pool fleet.RedisPool, conn redis.Conn) redis.Conn {
return conn
}
// Simplified interface for the ConfigureDoer function.
type getPool interface {
Get() redis.Conn
}
// ConfigureDoer configures conn to follow redirections if the redis
// configuration requested it and the pool is a Redis Cluster pool. If the conn
// is already in error, or if it is not a redisc cluster connection, it is
// returned unaltered.
func ConfigureDoer(pool fleet.RedisPool, conn redis.Conn) redis.Conn {
func ConfigureDoer(pool getPool, conn redis.Conn) redis.Conn {
if p, isCluster := pool.(*clusterPool); isCluster {
if err := conn.Err(); err == nil && p.followRedirs {
rc, err := redisc.RetryConn(conn, 3, 300*time.Millisecond)

11
server/fleet/acme.go Normal file
View file

@ -0,0 +1,11 @@
package fleet
import "context"
// ACMEWriteService is the subset of the ACME service module service
// used by the legacy service layer for write operations.
type ACMEWriteService interface {
// NewACMEEnrollment creates a new enrollment in the acme_enrollments table with the specified
// host_uuid and returns a new path_identifier for the created row.
NewACMEEnrollment(ctx context.Context, hostIdentifier string) (string, error)
}

View file

@ -238,6 +238,10 @@ type MDM struct {
EnableTurnOnWindowsMDMManually bool `json:"enable_turn_on_windows_mdm_manually"`
EndUserAuthentication MDMEndUserAuthentication `json:"end_user_authentication"`
// AppleRequireHardwareAttestation indicates whether to require Managed Device Attestation via ACME(including hardware bound keys) for
// certain Apple MDM enrollments.
AppleRequireHardwareAttestation bool `json:"apple_require_hardware_attestation"`
WindowsEntraTenantIDs optjson.Slice[string] `json:"windows_entra_tenant_ids"`
// WindowsEnabledAndConfigured indicates if Fleet MDM is enabled for Windows.

View file

@ -10,6 +10,7 @@ import (
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
@ -676,6 +677,13 @@ type SCEPIdentityAssociation struct {
EnrollmentType string `db:"type"`
}
type DeviceInfoForACMERenewal struct {
HostUUID string `db:"host_uuid"`
HardwareSerial string `db:"hardware_serial"`
HardwareModel string `db:"hardware_model"`
OSVersion string `db:"os_version"`
}
// MDMAppleDeclaration represents a DDM JSON declaration.
type MDMAppleDeclaration struct {
// DeclarationUUID is the unique identifier of the declaration in
@ -1039,6 +1047,66 @@ type MDMAppleMachineInfo struct {
Version string `plist:"VERSION"`
}
// macProductRe matches a macOS model identifier such as "MacBookPro18,3", capturing the
// alphabetic family prefix (group 1) and the numeric major version (group 2).
var macProductRe = regexp.MustCompile(`^([A-Za-z]+)(\d+),\d+$`)
// appleSiliconMajorThreshold maps each traditional Mac product family to the first major
// version number that corresponds to an Apple Silicon model. Any major version equal to or
// greater than the threshold is Apple Silicon; lower versions are x86.
var appleSiliconMajorThreshold = map[string]int{
// MacBookAir10,1 was the first Apple Silicon MacBook Air (M1, Late 2020).
"MacBookAir": 10,
// MacBookPro17,1 was the first Apple Silicon MacBook Pro (M1, Late 2020).
"MacBookPro": 17,
// Macmini9,1 was the first Apple Silicon Mac mini (M1, Late 2020).
"Macmini": 9,
// iMac21,1 was the first Apple Silicon iMac (M1, Early 2021).
"iMac": 21,
}
// IsMacAppleSilicon determines whether the device is an Apple Silicon Mac. If the model identifier
// starts with iPhone, iPod, or iPad, it returns false with no error; however, other non-Mac Apple
// devices like AppleTV will return an error.
func IsMacAppleSilicon(modelIdentifier string) (bool, error) {
if strings.HasPrefix(modelIdentifier, "iPhone") ||
strings.HasPrefix(modelIdentifier, "iPod") ||
strings.HasPrefix(modelIdentifier, "iPad") {
// If the model identifier starts with iPhone, iPod, or iPad, we'll return false with no
// error; however, other non-Mac Apple devices like AppleTV will return an error
return false, nil
}
matches := macProductRe.FindStringSubmatch(modelIdentifier)
if matches == nil {
return false, fmt.Errorf("unrecognized product identifier format: %q", modelIdentifier)
}
family := matches[1]
major, _ := strconv.Atoi(matches[2])
// Model identifiers starting with "Mac" immediately followed by a digit (e.g. "Mac13,1")
// represent the unified naming scheme Apple adopted for Apple Silicon products such as the
// Mac Studio and the M2/M3/M4-era Mac Pro. All such identifiers are Apple Silicon.
if family == "Mac" {
return true, nil
}
// MacBook (no suffix), iMacPro, and MacPro were all discontinued before Apple Silicon
// was introduced; every model in these families is x86.
switch family {
case "MacBook", "iMacPro", "MacPro":
return false, nil
}
threshold, ok := appleSiliconMajorThreshold[family]
if !ok {
return false, fmt.Errorf("unrecognized Mac product family in identifier: %q", modelIdentifier)
}
return major >= threshold, nil
}
// MDMAppleAccountDrivenUserEnrollDeviceInfo is a more minimal version of DeviceInfo sent on Account
// Driven User Enrollment requests[1] that only describes the base product attempting enrollment.
//

View file

@ -829,3 +829,92 @@ func TestValidateNoSecretsInProfileName(t *testing.T) {
})
}
}
func TestIsMacAppleSilicon(t *testing.T) {
cases := []struct {
product string
wantAS bool
wantErr bool
}{
// --- MacBookPro ---
// x86: last Intel model before Apple Silicon transition
{product: "MacBookPro16,1", wantAS: false},
{product: "MacBookPro16,4", wantAS: false},
// Apple Silicon: first AS model (M1, Late 2020) and later
{product: "MacBookPro17,1", wantAS: true},
{product: "MacBookPro18,3", wantAS: true},
{product: "MacBookPro18,4", wantAS: true},
// --- MacBookAir ---
// x86: last Intel model before Apple Silicon transition
{product: "MacBookAir9,1", wantAS: false},
// Apple Silicon: first AS model (M1, Late 2020) and later
{product: "MacBookAir10,1", wantAS: true},
{product: "MacBookAir14,2", wantAS: true},
// --- Macmini ---
// x86: last Intel model before Apple Silicon transition
{product: "Macmini8,1", wantAS: false},
// Apple Silicon: first AS model (M1, Late 2020) and later
{product: "Macmini9,1", wantAS: true},
{product: "Macmini9,2", wantAS: true},
// --- iMac ---
// x86: last Intel models before Apple Silicon transition
{product: "iMac20,1", wantAS: false},
{product: "iMac20,2", wantAS: false},
// Apple Silicon: first AS model (M1, Early 2021) and later
{product: "iMac21,1", wantAS: true},
{product: "iMac21,2", wantAS: true},
// --- MacBook (no suffix) — all x86, line discontinued before Apple Silicon ---
{product: "MacBook10,1", wantAS: false},
{product: "MacBook9,1", wantAS: false},
// --- iMacPro — all x86, discontinued before Apple Silicon ---
{product: "iMacPro1,1", wantAS: false},
// --- MacPro — old numbering, all x86 ---
// (the AS Mac Pro uses the "Mac" prefix, e.g. Mac14,8)
{product: "MacPro7,1", wantAS: false},
{product: "MacPro6,1", wantAS: false},
// --- Mac (bare prefix) — unified Apple Silicon naming ---
// Mac Studio (M1 Ultra, 2022)
{product: "Mac13,1", wantAS: true},
{product: "Mac13,2", wantAS: true},
// Mac Pro (M2 Ultra, 2023)
{product: "Mac14,8", wantAS: true},
// Mac mini (M4, 2024)
{product: "Mac16,10", wantAS: true},
// --- Non-Mac Apple devices — return false without error ---
{product: "iPhone15,2", wantAS: false},
{product: "iPhone14,3", wantAS: false},
{product: "iPad13,18", wantAS: false},
{product: "iPodTouch9,1", wantAS: false},
// --- Error cases ---
// Empty string
{product: "", wantErr: true},
// No comma separator
{product: "MacBookPro18", wantErr: true},
// Garbage input
{product: "not-a-model", wantErr: true},
// Non-Mac Apple devices that don't start with iPhone/iPod/iPad return an error
{product: "AppleTV6,2", wantErr: true},
{product: "AppleTV14,1", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.product, func(t *testing.T) {
got, err := IsMacAppleSilicon(tc.product)
if tc.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
require.Equal(t, tc.wantAS, got)
}
})
}
}

View file

@ -1156,6 +1156,9 @@ type Datastore interface {
// progress based on the provided arguments.
GetHostCertAssociationsToExpire(ctx context.Context, expiryDays, limit int) ([]SCEPIdentityAssociation, error)
// GetDeviceInfoForACMERenewal retrieves the device information for ACMERenewal based on the provided host UUIDs.
GetDeviceInfoForACMERenewal(ctx context.Context, hostUUIDs []string) ([]DeviceInfoForACMERenewal, error)
// SetCommandForPendingSCEPRenewal tracks the command used to renew a scep certificate
SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []SCEPIdentityAssociation, cmdUUID string) error
@ -1437,6 +1440,9 @@ type Datastore interface {
// GetHostDEPAssignment returns the DEP assignment for the host.
GetHostDEPAssignment(ctx context.Context, hostID uint) (*HostDEPAssignment, error)
// GetHostDEPAssignmentsBySerial returns the DEP assignment for the host with the specified serial number.
GetHostDEPAssignmentsBySerial(ctx context.Context, serial string) ([]*HostDEPAssignment, error)
// GetNanoMDMEnrollment returns the nano enrollment information for the device id.
GetNanoMDMEnrollment(ctx context.Context, id string) (*NanoEnrollment, error)
@ -1455,9 +1461,9 @@ type Datastore interface {
// overriden by a TokenUpdate but that should provide the latest username
UpdateNanoMDMUserEnrollmentUsername(ctx context.Context, deviceID string, userUUID string, username string) error
// GetNanoMDMEnrollmentTimes returns the time of the most recent enrollment and the most recent
// MDM protocol seen time for the host with the given UUID
GetNanoMDMEnrollmentTimes(ctx context.Context, hostUUID string) (*time.Time, *time.Time, error)
// GetNanoMDMEnrollmentDetails returns the time of the most recent enrollment, the most recent
// MDM protocol seen time, and whether the enrollment is hardware attested for the host with the given UUID
GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error)
// IncreasePolicyAutomationIteration marks the policy to fire automation again.
IncreasePolicyAutomationIteration(ctx context.Context, policyID uint) error

View file

@ -958,6 +958,8 @@ type HostDetail struct {
LastMDMEnrolledAt *time.Time `json:"last_mdm_enrolled_at"`
LastMDMCheckedInAt *time.Time `json:"last_mdm_checked_in_at"`
MDMEnrollmentHardwareAttested bool `json:"mdm_enrollment_hardware_attested"`
ConditionalAccessBypassed bool `json:"conditional_access_bypassed"`
}

View file

@ -655,6 +655,14 @@ type Service interface {
// This should be called after service creation to inject the activity service dependency.
SetActivityService(activitySvc ActivityWriteService)
// SetACMEService sets the ACME service module for write operations.
// This should be called after service creation to inject the ACME service dependency.
SetACMEService(acmeSvc ACMEWriteService)
// NewACMEEnrollment creates a new ACME enrollment using the ACME service module. It returns the
// ACME identifier for the new enrollment, which is used to track the enrollment process and link it to a host.
NewACMEEnrollment(ctx context.Context, hostIdentifier string) (string, error)
// NewActivity creates the given activity on the datastore.
//
// What we call "Activities" are administrative operations,
@ -929,7 +937,7 @@ type Service interface {
GetMDMAppleProfilesSummary(ctx context.Context, teamID *uint) (*MDMProfilesSummary, error)
// GetMDMAppleEnrollmentProfileByToken returns the Apple enrollment from its secret token.
GetMDMAppleEnrollmentProfileByToken(ctx context.Context, enrollmentToken string, enrollmentRef string) (profile []byte, err error)
GetMDMAppleEnrollmentProfileByToken(ctx context.Context, enrollmentToken string, enrollmentRef string, machineInfo *MDMAppleMachineInfo) (profile []byte, err error)
// GetMDMAppleEnrollmentProfileByToken returns the Apple account-driven user enrollment profile for a given enrollment reference.
GetMDMAppleAccountEnrollmentProfile(ctx context.Context, enrollReference string) (profile []byte, err error)

View file

@ -0,0 +1,20 @@
package api
import (
"context"
api_http "github.com/fleetdm/fleet/v4/server/mdm/acme/api/http"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"go.step.sm/crypto/jose"
)
type AccountService interface {
CreateAccount(ctx context.Context, pathIdentifier string, enrollmentID uint, jwk jose.JSONWebKey, onlyReturnExisting bool) (*types.AccountResponse, error)
AuthenticateMessageFromAccount(ctx context.Context, message *api_http.JWSRequestContainer, request types.AccountAuthenticatedRequest) error
AuthenticateNewAccountMessage(ctx context.Context, message *api_http.JWSRequestContainer, request *api_http.CreateNewAccountRequest) error
CreateOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, partialOrder *types.Order) (*types.OrderResponse, error)
FinalizeOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, orderID uint, csr string) (*types.OrderResponse, error)
GetOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, orderID uint) (*types.OrderResponse, error)
ListAccountOrders(ctx context.Context, pathIdentifier string, account *types.Account) ([]string, error)
GetCertificate(ctx context.Context, accountID, orderID uint) (string, error)
}

View file

@ -0,0 +1,12 @@
package api
import (
"context"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
)
// AuthorizationService does not handle normal authentication, but the ACME concept of authorization as part of the protocol.
type AuthorizationService interface {
GetAuthorization(ctx context.Context, enrollment *types.Enrollment, account *types.Account, authorizationID uint) (*types.AuthorizationResponse, error)
}

View file

@ -0,0 +1,11 @@
package api
import (
"context"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
)
type ChallengeService interface {
ValidateChallenge(ctx context.Context, enrollment *types.Enrollment, account *types.Account, challengeID uint, payload string) (*types.ChallengeResponse, error)
}

View file

@ -0,0 +1,12 @@
package api
import (
"context"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
)
type DirectoryNonceService interface {
NewNonce(ctx context.Context, identifier string) error
GetDirectory(ctx context.Context, identifier string) (*types.Directory, error)
}

View file

@ -0,0 +1,10 @@
package api
import "context"
// EnrollmentService stores records in the acme_enrollments table.
type EnrollmentService interface {
// NewACMEEnrollment creates a new enrollment in the acme_enrollments table with the specified
// host identifier and returns a new path_identifier for the created row.
NewACMEEnrollment(ctx context.Context, hostIdentifier string) (string, error)
}

View file

@ -0,0 +1,455 @@
// Package http provides HTTP request and response types for the ACME service module.
// These types are used exclusively by the ACME endpoint handler.
package http
import (
"context"
"crypto/x509"
"errors"
"io"
"net/http"
"net/url"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/redis_nonces_store"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"go.step.sm/crypto/jose"
)
func generateAndRenderNonce(ctx context.Context, nonces *redis_nonces_store.RedisNoncesStore, w http.ResponseWriter) error {
nonce := types.CreateNonceEncodedForHeader()
if err := nonces.Store(ctx, nonce, redis_nonces_store.DefaultNonceExpiration); err != nil {
return err
}
w.Header().Set("Replay-Nonce", nonce)
w.Header().Set("Cache-Control", "no-store")
return nil
}
type GetNewNonceRequest struct {
// HTTPMethod used to make this request, populated by the parse custom URL
// tag function of the ACME service module, which is one of the only ways
// with our framework to access the *http.Request value.
HTTPMethod string `url:"http_method"`
Identifier string `url:"identifier"`
}
type GetNewNonceResponse struct {
HTTPMethod string `json:"-"`
Err error `json:"error,omitempty"`
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
}
// Error implements the platform_http.Errorer interface.
func (r GetNewNonceResponse) Error() error { return r.Err }
// BeforeRender implements the beforeRenderer interface.
func (r *GetNewNonceResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
// only generate a new nonce if there are no error for this endpoint.
if r.Err == nil {
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
r.Err = err
return
}
}
}
func (r *GetNewNonceResponse) Status() int {
if r.HTTPMethod == http.MethodHead {
return http.StatusOK
}
// for GET/POST-as-GET, return 204
return http.StatusNoContent
}
type GetDirectoryRequest struct {
Identifier string `url:"identifier"`
}
type GetDirectoryResponse struct {
*types.Directory
Err error `json:"error,omitempty"`
}
// Error implements the platform_http.Errorer interface.
func (r GetDirectoryResponse) Error() error { return r.Err }
type CreateNewAccountRequest struct {
Enrollment *types.Enrollment `json:"-"`
JSONWebKey *jose.JSONWebKey `json:"-"`
// OnlyReturnExisting indicates that no new account should be created but the
// existing account for this key should be returned if it exists. This is the
// only actual parameter read from the payload of the JWS request
OnlyReturnExisting bool `json:"onlyReturnExisting"`
}
type CreateNewAccountResponse struct {
*types.AccountResponse
Err error `json:"error,omitempty"`
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
}
// BeforeRender implements the beforeRenderer interface.
func (r *CreateNewAccountResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
// only generate a new nonce if there is no error or the error is due to a client error
// other than "enrollment not found" (in which case the client has no reason to retry).
if r.Err != nil {
var acmeErr *types.ACMEError
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
return
}
}
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
r.Err = err
return
}
if r.AccountResponse != nil && r.AccountResponse.Location != "" {
w.Header().Set("Location", r.AccountResponse.Location)
}
}
// Status implements the statuser interface.
func (r *CreateNewAccountResponse) Status() int {
if r.DidCreate {
return http.StatusCreated
}
return http.StatusOK
}
// Error implements the platform_http.Errorer interface.
func (r CreateNewAccountResponse) Error() error { return r.Err }
type CreateNewOrderRequest struct {
types.AccountAuthenticatedRequestBase
Identifiers []types.Identifier `json:"identifiers"`
// NotBefore and NotAfter must not be set, we capture them so we can validate
// that they were indeed not provided.
NotBefore *time.Time `json:"notBefore"`
NotAfter *time.Time `json:"notAfter"`
}
type CreateNewOrderResponse struct {
*types.OrderResponse
Err error `json:"error,omitempty"`
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
}
func (r *CreateNewOrderResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
// only generate a new nonce if there is no error or the error is due to a client error
// other than "enrollment not found" (in which case the client has no reason to retry).
if r.Err != nil {
var acmeErr *types.ACMEError
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
return
}
}
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
r.Err = err
return
}
if r.OrderResponse != nil && r.OrderResponse.Location != "" {
w.Header().Set("Location", r.OrderResponse.Location)
}
}
// Error implements the platform_http.Errorer interface.
func (r CreateNewOrderResponse) Error() error { return r.Err }
// Status implements the statuser interface.
func (r *CreateNewOrderResponse) Status() int { return http.StatusCreated }
type GetOrderDecodedRequest struct {
types.AccountAuthenticatedRequestBase
OrderID uint `json:"-"`
}
type GetOrderRequest struct {
JWSRequestContainer
OrderID uint `url:"order_id"`
}
type GetOrderResponse struct {
*types.OrderResponse
Err error `json:"error,omitempty"`
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
}
func (r *GetOrderResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
// only generate a new nonce if there is no error or the error is due to a client error
// other than "enrollment not found" (in which case the client has no reason to retry).
if r.Err != nil {
var acmeErr *types.ACMEError
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
return
}
}
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
r.Err = err
return
}
}
// Error implements the platform_http.Errorer interface.
func (r GetOrderResponse) Error() error { return r.Err }
type GetAuthorizationRequest struct {
JWSRequestContainer
AuthorizationID uint `url:"authorization_id"`
}
type GetAuthorizationDecodedRequest struct {
types.AccountAuthenticatedRequestBase
AuthorizationID uint `json:"-"`
}
type GetAuthorizationResponse struct {
*types.AuthorizationResponse
Err error `json:"error,omitempty"`
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
}
func (r *GetAuthorizationResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
// only generate a new nonce if there is no error or the error is due to a client error
// other than "enrollment not found" (in which case the client has no reason to retry).
if r.Err != nil {
var acmeErr *types.ACMEError
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
return
}
}
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
r.Err = err
return
}
if r.AuthorizationResponse != nil && r.AuthorizationResponse.Location != "" {
w.Header().Set("Location", r.AuthorizationResponse.Location)
}
}
func (r GetAuthorizationResponse) Error() error { return r.Err }
type ListOrdersDecodedRequest struct {
types.AccountAuthenticatedRequestBase
AccountID uint `json:"-"`
}
type ListOrdersRequest struct {
JWSRequestContainer
AccountID uint `url:"account_id"`
}
type ListOrdersResponse struct {
Orders []string `json:"orders"`
Err error `json:"error,omitempty"`
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
}
func (r *ListOrdersResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
// only generate a new nonce if there is no error or the error is due to a client error
// other than "enrollment not found" (in which case the client has no reason to retry).
if r.Err != nil {
var acmeErr *types.ACMEError
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
return
}
}
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
r.Err = err
return
}
}
// Error implements the platform_http.Errorer interface.
func (r ListOrdersResponse) Error() error { return r.Err }
type GetCertificateDecodedRequest struct {
types.AccountAuthenticatedRequestBase
OrderID uint `json:"-"`
}
type GetCertificateRequest struct {
JWSRequestContainer
OrderID uint `url:"order_id"`
}
type GetCertificateResponse struct {
// Certificate is the PEM-encoded certificate chain, will be rendered manually
// via HijackRender on success.
Certificate string `json:"-"`
Err error `json:"error,omitempty"`
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
}
func (r *GetCertificateResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
// only generate a new nonce if there is no error or the error is due to a client error
// other than "enrollment not found" (in which case the client has no reason to retry).
if r.Err != nil {
var acmeErr *types.ACMEError
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
return
}
}
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
r.Err = err
return
}
}
func (r *GetCertificateResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
w.Header().Set("Content-Type", "application/pem-certificate-chain")
_, _ = w.Write([]byte(r.Certificate))
}
// Error implements the platform_http.Errorer interface.
func (r GetCertificateResponse) Error() error { return r.Err }
type DoChallengeRequest struct {
JWSRequestContainer
ChallengeID uint `url:"challenge_id"`
}
type DoChallengeDecodedRequest struct {
types.AccountAuthenticatedRequestBase
AttestationObject string `json:"attObj"`
AttestError string `json:"error"`
ChallengeID uint `json:"-"`
}
type DoChallengeResponse struct {
*types.ChallengeResponse
Err error `json:"error,omitempty"`
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
}
func (r *DoChallengeResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
// only generate a new nonce if there is no error or the error is due to a client error
// other than "enrollment not found" (in which case the client has no reason to retry).
if r.Err != nil {
var acmeErr *types.ACMEError
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
return
}
}
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
r.Err = err
return
}
if r.ChallengeResponse != nil && r.ChallengeResponse.Location != "" {
w.Header().Set("Location", r.ChallengeResponse.Location)
}
}
func (r DoChallengeResponse) Error() error { return r.Err }
// JWS Request container is a container for doing basic decoding and validation operations common to all
// authenticated ACME requests, which come in the form of a JWS in flattened serialization syntax. This is
// parsed into a jose.JSONWebSignature with some basic validation done on it and then the downstream
// handler can use the included JWK or KeyID to do further authentication and authorization as needed.
type JWSRequestContainer struct {
JWS jose.JSONWebSignature
JWSHeaderURL string
Key *jose.JSONWebKey
KeyID *string
// PostAsGet indicates that this POST request is semantically a GET, and as
// such should not have any payload in the JWS.
PostAsGet bool
// Fields extracted from the URL path.
Identifier string `url:"identifier"`
HTTPPath string `url:"http_path"`
}
func (req *JWSRequestContainer) DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error {
jwsBytes, err := io.ReadAll(r)
if err != nil {
return ctxerr.Wrap(ctx, err, "reading ACME JWS request body")
}
// Note: req.Identifier is set from the mux path variable by the framework
// (via the `url:"identifier"` struct tag), so we don't set it here.
jws, err := jose.ParseJWS(string(jwsBytes))
if err != nil {
err = types.MalformedError("Failed to parse JWS body")
return ctxerr.Wrap(ctx, err)
}
// The JWS must have exactly one signature because ACME uses the "flattened" JWS JSON serialization
if len(jws.Signatures) == 0 {
err = types.MalformedError("JWS must have a signature")
return ctxerr.Wrap(ctx, err)
}
if len(jws.Signatures) > 1 {
err = types.MalformedError("JWS must have only one signature")
return ctxerr.Wrap(ctx, err)
}
// All ACME requests should have either a JWK in the header or a KeyID that points to an account, but never both
if jws.Signatures[0].Protected.JSONWebKey == nil && jws.Signatures[0].Protected.KeyID == "" {
err = types.MalformedError("JWS must have a key or key ID in the protected header")
return ctxerr.Wrap(ctx, err)
}
if jws.Signatures[0].Protected.JSONWebKey != nil && jws.Signatures[0].Protected.KeyID != "" {
err = types.MalformedError("JWS must not have both a key and key ID in the protected header")
return ctxerr.Wrap(ctx, err)
}
req.JWS = *jws
if jws.Signatures[0].Protected.JSONWebKey != nil {
req.Key = jws.Signatures[0].Protected.JSONWebKey
}
// KeyID should be the account URL
if jws.Signatures[0].Protected.KeyID != "" {
req.KeyID = &jws.Signatures[0].Protected.KeyID
}
// JWS must have a url field in the protected header:
// https://datatracker.ietf.org/doc/html/rfc8555/#section-6.2
headerURL, ok := jws.Signatures[0].Protected.ExtraHeaders["url"].(string)
if !ok || headerURL == "" {
err = types.MalformedError("JWS must have a url in the protected header")
return ctxerr.Wrap(ctx, err)
}
req.JWSHeaderURL = headerURL
return nil
}
type FinalizeOrderRequestContainer struct {
JWSRequestContainer
OrderID uint `url:"order_id"`
}
type FinalizeOrderRequest struct {
types.AccountAuthenticatedRequestBase
OrderID uint `json:"-"`
CertificateSigningRequest string `json:"csr"`
}
type FinalizeOrderResponse struct {
*types.OrderResponse
Err error `json:"error,omitempty"`
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
}
func (r *FinalizeOrderResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
// only generate a new nonce if there is no error or the error is due to a client error
// other than "enrollment not found" (in which case the client has no reason to retry).
if r.Err != nil {
var acmeErr *types.ACMEError
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
return
}
}
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
r.Err = err
return
}
if r.OrderResponse != nil && r.OrderResponse.Location != "" {
w.Header().Set("Location", r.OrderResponse.Location)
}
}
// Error implements the platform_http.Errorer interface.
func (r FinalizeOrderResponse) Error() error { return r.Err }

View file

@ -0,0 +1,16 @@
// Package api provides the public API for the ACME service module.
// External code should use this package to interact with ACME.
package api
import "github.com/fleetdm/fleet/v4/server/mdm/acme/internal/redis_nonces_store"
// Service is the composite interface for the ACME service module.
// It embeds all method-specific interfaces. Bootstrap returns this type.
type Service interface {
DirectoryNonceService
AccountService
EnrollmentService
AuthorizationService
ChallengeService
NoncesStore() *redis_nonces_store.RedisNoncesStore
}

View file

@ -0,0 +1,146 @@
package acme_test
import (
"regexp"
"testing"
"github.com/fleetdm/fleet/v4/server/archtest"
)
const m = archtest.ModuleName
var (
fleetDeps = regexp.MustCompile(`^github\.com/fleetdm/`)
// Common allowed dependencies across acme packages
acmePkgs = []string{
m + "/server/mdm/acme",
m + "/server/mdm/acme/api",
m + "/server/mdm/acme/api/http",
m + "/server/mdm/acme/internal/types",
// TODO: redis_nonces_store should not leak through the API layer.
// It's here because api.Service exposes NoncesStore() and the HTTP
// response types reference it for nonce generation in BeforeRender.
m + "/server/mdm/acme/internal/redis_nonces_store",
}
platformPkgs = []string{
m + "/server/ptr",
m + "/server/platform/...",
m + "/server/contexts/...",
m + "/server/mdm/internal/commonmdm",
m + "/pkg/fleethttp",
}
)
// TestACMEPackageDependencies runs architecture tests for all ACME packages.
// Each package has specific rules about what dependencies are allowed.
func TestACMEPackageDependencies(t *testing.T) {
t.Parallel()
cases := []struct {
name string
pkg string
shouldNotDepend []string // defaults to m + "/..." if empty
ignoreDeps []string
ignoreRecursively []string // for test infra packages whose transitive deps we don't control
skip bool // Temp flag to skip tests that will end up using the redis package, and therefore pull in all of server/fleet, we need to move the datastore redis package out, to enable these.
}{
{
name: "root package has no Fleet dependencies",
pkg: m + "/server/mdm/acme",
},
{
name: "api package only depends on acme and platform packages",
pkg: m + "/server/mdm/acme/api",
ignoreDeps: append(acmePkgs, platformPkgs...),
skip: true,
},
{
name: "api/http depends on api and platform",
pkg: m + "/server/mdm/acme/api/http",
ignoreDeps: append(append([]string{
m + "/server/mdm/acme/api",
}, acmePkgs...), platformPkgs...),
skip: true,
},
{
name: "internal/types only depends on api",
pkg: m + "/server/mdm/acme/internal/types",
ignoreDeps: []string{m + "/server/mdm/acme/api"},
},
{
name: "internal/mysql depends on api, types, and platform",
pkg: m + "/server/mdm/acme/internal/mysql",
ignoreDeps: append([]string{
m + "/server/mdm/acme/api",
m + "/server/mdm/acme/internal/types",
m + "/server/mdm/acme/internal/testutils",
}, platformPkgs...),
},
{
name: "internal/service depends on acme and platform packages",
pkg: m + "/server/mdm/acme/internal/service",
ignoreDeps: append(append([]string{
m + "/server/ptr",
m + "/server/mdm/acme/internal/redis_nonces_store",
}, acmePkgs...), platformPkgs...),
skip: true,
},
{
name: "bootstrap depends on acme and platform packages",
pkg: m + "/server/mdm/acme/bootstrap",
ignoreDeps: append(append([]string{
m + "/server/mdm/acme/internal/mysql",
m + "/server/mdm/acme/internal/service",
m + "/server/ptr",
}, acmePkgs...), platformPkgs...),
skip: true,
},
{
name: "all packages only depend on acme and platform",
pkg: m + "/server/mdm/acme/...",
ignoreDeps: append(append([]string{
m + "/server/ptr",
m + "/server/mdm/acme/internal/mysql",
m + "/server/mdm/acme/internal/service",
m + "/server/mdm/acme/internal/testutils",
m + "/server/mdm/acme/testhelpers",
}, acmePkgs...), platformPkgs...),
// Test infrastructure packages whose transitive deps (fleet, etc.) we don't control.
ignoreRecursively: []string{
m + "/server/datastore/redis/redistest",
m + "/server/test",
},
skip: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if tc.skip {
t.Skip("Skipping test, due to pulling in server/datastore/redis, which pulls in server/fleet.")
}
t.Parallel()
shouldNotDepend := tc.shouldNotDepend
if len(shouldNotDepend) == 0 {
shouldNotDepend = []string{m + "/..."}
}
test := archtest.NewPackageTest(t, tc.pkg).
OnlyInclude(fleetDeps).
ShouldNotDependOn(shouldNotDepend...).
WithTests()
if len(tc.ignoreDeps) > 0 {
test.IgnoreDeps(tc.ignoreDeps...)
}
if len(tc.ignoreRecursively) > 0 {
test.IgnoreRecursively(tc.ignoreRecursively...)
}
test.Check()
})
}
}

View file

@ -0,0 +1,42 @@
// Package bootstrap provides the public entry point for the ACME service module.
// It wires together internal components and exposes them for use in serve.go.
package bootstrap
import (
"crypto/x509"
"log/slog"
"github.com/fleetdm/fleet/v4/server/mdm/acme"
"github.com/fleetdm/fleet/v4/server/mdm/acme/api"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/mysql"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/service"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/go-kit/kit/endpoint"
)
type ServiceOption = service.ServiceOption
// New creates a new ACME service module and returns its service and route handler.
func New(
dbConns *platform_mysql.DBConnections,
redisPool acme.RedisPool,
providers acme.DataProviders,
logger *slog.Logger,
opts ...ServiceOption,
) (api.Service, func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc) {
ds := mysql.NewDatastore(dbConns, logger)
svc := service.NewService(ds, redisPool, providers, logger, opts...)
routesFn := func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc {
return service.GetRoutes(svc, authMiddleware)
}
return svc, routesFn
}
func WithTestAppleRootCAs(rootCAs *x509.CertPool) ServiceOption {
return func(svc *service.Service) {
svc.TestAppleRootCAs = rootCAs
}
}

View file

@ -0,0 +1,287 @@
package mysql
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/jmoiron/sqlx"
"go.step.sm/crypto/jose"
)
const (
maxAccountsPerEnrollment = 3
maxOrdersPerAccount = 3
)
func (ds *Datastore) CreateAccount(ctx context.Context, account *types.Account, onlyReturnExisting bool) (*types.Account, bool, error) {
var didCreate bool
err := platform_mysql.WithRetryTxx(ctx, ds.primary, func(tx sqlx.ExtContext) error {
// Mark the enrollment as locked to prevent concurrent account creation for
// the same enrollment, so we can enforce limits on account creation
const lockEnrollmentStmt = `SELECT id FROM acme_enrollments WHERE id = ? FOR UPDATE`
var enrollmentID uint
err := sqlx.GetContext(ctx, tx, &enrollmentID, lockEnrollmentStmt, account.ACMEEnrollmentID)
if err != nil {
return ctxerr.Wrap(ctx, err, "lock acme enrollment")
}
thumbprint, err := jose.Thumbprint(&account.JSONWebKey)
if err != nil {
return ctxerr.Wrap(ctx, err, "compute jwk thumbprint for new account")
}
// if the account already exists (and is not revoked), we return it
const findExistingAccountStmt = `SELECT id, revoked FROM acme_accounts WHERE acme_enrollment_id = ? AND json_web_key_thumbprint = ?`
var existingAccount struct {
ID uint `db:"id"`
Revoked bool `db:"revoked"`
}
err = sqlx.GetContext(ctx, tx, &existingAccount, findExistingAccountStmt, account.ACMEEnrollmentID, thumbprint)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return ctxerr.Wrap(ctx, err, "check for existing account with same jwk thumbprint")
}
if err == nil {
if existingAccount.Revoked {
err = types.AccountRevokedError(fmt.Sprintf("Account %d is revoked", existingAccount.ID))
return ctxerr.Wrap(ctx, err)
}
account.ID = existingAccount.ID
return nil
}
// If we got here we didn't find an existing account so, if requested by the caller, return a notFound
if onlyReturnExisting {
err = types.AccountDoesNotExistError(fmt.Sprintf("No account exists for enrollment id %d with the provided jwk", account.ACMEEnrollmentID))
return ctxerr.Wrap(ctx, err)
}
// check if maximum number of accounts for this enrollment has been reached before creating a new one
const countAccountsStmt = `SELECT COUNT(*) FROM acme_accounts WHERE acme_enrollment_id = ?`
var accountCount int
err = sqlx.GetContext(ctx, tx, &accountCount, countAccountsStmt, enrollmentID)
if err != nil {
return ctxerr.Wrap(ctx, err, "count acme accounts for enrollment")
}
if accountCount >= maxAccountsPerEnrollment {
err = types.TooManyAccountsError(fmt.Sprintf("Enrollment id %d already has %d accounts, which is the maximum allowed", enrollmentID, accountCount))
return ctxerr.Wrap(ctx, err)
}
// create the new account
jwkSerialized, err := account.JSONWebKey.MarshalJSON()
if err != nil {
return ctxerr.Wrap(ctx, err, "marshal new account jwk")
}
const insertStmt = `INSERT INTO acme_accounts (acme_enrollment_id, json_web_key, json_web_key_thumbprint) VALUES (?, ?, ?)`
res, err := tx.ExecContext(ctx, insertStmt, account.ACMEEnrollmentID, jwkSerialized, thumbprint)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert acme account")
}
lastInsertID, _ := res.LastInsertId() // can never fail with mysql
account.ID = uint(lastInsertID)
didCreate = true
// if the acme_enrollment has a NULL not_valid_after it should be set to a value
// 24 hours in the future so that now that this enrollment is being used it will expire
const updateEnrollmentStmt = `UPDATE acme_enrollments SET not_valid_after = COALESCE(not_valid_after, DATE_ADD(NOW(), INTERVAL 24 HOUR)) WHERE id = ?`
_, err = tx.ExecContext(ctx, updateEnrollmentStmt, enrollmentID)
if err != nil {
return ctxerr.Wrap(ctx, err, "update acme enrollment not_valid_after")
}
return nil
}, ds.logger)
if err != nil {
return nil, false, err
}
return account, didCreate, nil
}
// This method specifically requires an enrollment ID because
// the caller should know it and have verified it.
func (ds *Datastore) GetAccountByID(ctx context.Context, enrollmentID uint, accountID uint) (*types.Account, error) {
const stmt = `SELECT id, acme_enrollment_id, json_web_key, json_web_key_thumbprint
FROM acme_accounts
WHERE acme_enrollment_id = ? AND id = ? AND revoked = false`
var dbAcc struct {
types.Account
JSONWebKeyRaw []byte `db:"json_web_key"`
}
err := sqlx.GetContext(ctx, ds.reader(ctx), &dbAcc, stmt, enrollmentID, accountID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
err = types.AccountDoesNotExistError(fmt.Sprintf("No account exists with id %d", accountID))
return nil, ctxerr.Wrap(ctx, err)
}
return nil, ctxerr.Wrap(ctx, err, "select acme account")
}
var jwk jose.JSONWebKey
if err := jwk.UnmarshalJSON(dbAcc.JSONWebKeyRaw); err != nil {
return nil, ctxerr.Wrap(ctx, err, "unmarshal acme account jwk")
}
dbAcc.JSONWebKey = jwk
return &dbAcc.Account, nil
}
// NB: We are leaving it to the caller to set the proper status, token, etc on the order, authorization and challenge
func (ds *Datastore) CreateOrder(ctx context.Context, order *types.Order, authorization *types.Authorization, challenge *types.Challenge) (*types.Order, error) {
err := platform_mysql.WithRetryTxx(ctx, ds.primary, func(tx sqlx.ExtContext) error {
// Mark the account as locked to prevent concurrent order creation for the same account, so we can enforce limits on order creation
const lockAccountStmt = `SELECT id FROM acme_accounts WHERE id = ? FOR UPDATE`
var accountID uint
err := sqlx.GetContext(ctx, tx, &accountID, lockAccountStmt, order.ACMEAccountID)
if err != nil {
return ctxerr.Wrap(ctx, err, "lock acme account")
}
const countOrdersStmt = `SELECT COUNT(*) FROM acme_orders WHERE acme_account_id = ?`
var orderCount int
err = sqlx.GetContext(ctx, tx, &orderCount, countOrdersStmt, order.ACMEAccountID)
if err != nil {
return ctxerr.Wrap(ctx, err, "count acme orders for account")
}
if orderCount >= maxOrdersPerAccount {
err = types.TooManyOrdersError(fmt.Sprintf("Account id %d already has %d orders, which is the maximum allowed", order.ACMEAccountID, orderCount))
return ctxerr.Wrap(ctx, err)
}
identifiersSerialized, err := json.Marshal(order.Identifiers)
if err != nil {
return ctxerr.Wrap(ctx, err, "marshal order identifiers")
}
const insertOrderStmt = `INSERT INTO acme_orders (
acme_account_id, finalized, certificate_signing_request, identifiers, status, issued_certificate_serial
) VALUES (?, ?, ?, ?, ?, ?)`
res, err := tx.ExecContext(ctx, insertOrderStmt,
order.ACMEAccountID, order.Finalized, order.CertificateSigningRequest, identifiersSerialized, order.Status, order.IssuedCertificateSerial)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert acme order")
}
lastInsertID, _ := res.LastInsertId() // can never fail for mysql
order.ID = uint(lastInsertID)
const insertAuthorizationStmt = `INSERT INTO acme_authorizations (acme_order_id, identifier_type, identifier_value, status) VALUES (?, ?, ?, ?)`
res, err = tx.ExecContext(ctx, insertAuthorizationStmt, order.ID, authorization.Identifier.Type, authorization.Identifier.Value, authorization.Status)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert acme authorization")
}
lastInsertID, _ = res.LastInsertId() // can never fail for mysql
authorization.ID = uint(lastInsertID)
const insertChallengeStmt = `INSERT INTO acme_challenges (acme_authorization_id, challenge_type, token, status) VALUES (?, ?, ?, ?)`
res, err = tx.ExecContext(ctx, insertChallengeStmt, authorization.ID, challenge.ChallengeType, challenge.Token, challenge.Status)
if err != nil {
return ctxerr.Wrap(ctx, err, "insert acme challenge")
}
lastInsertID, _ = res.LastInsertId() // can never fail for mysql
challenge.ID = uint(lastInsertID)
challenge.ACMEAuthorizationID = authorization.ID
return nil
}, ds.logger)
if err != nil {
return nil, err
}
return order, nil
}
func (ds *Datastore) FinalizeOrder(ctx context.Context, orderID uint, csrPEM string, certSerial int64) error {
const stmt = `UPDATE acme_orders SET status = ?, finalized=1, certificate_signing_request = ?, issued_certificate_serial = ? WHERE id = ?`
_, err := ds.writer(ctx).ExecContext(ctx, stmt, types.OrderStatusValid, csrPEM, certSerial, orderID)
if err != nil {
return ctxerr.Wrap(ctx, err, "update acme order with signed certificate")
}
return nil
}
func (ds *Datastore) GetOrderByID(ctx context.Context, accountID, orderID uint) (*types.Order, []*types.Authorization, error) {
// condition is on both account and order ids, so that we don't get a match on
// just the order id that wouldn't be associated with the validated account from
// the request.
const getOrderStmt = `SELECT id, acme_account_id, finalized, certificate_signing_request, identifiers, status, issued_certificate_serial
FROM acme_orders WHERE acme_account_id = ? AND id = ?`
var dbOrder struct {
types.Order
RawIdentifiers []byte `db:"identifiers"`
}
err := sqlx.GetContext(ctx, ds.reader(ctx), &dbOrder, getOrderStmt, accountID, orderID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
err = types.OrderDoesNotExistError(fmt.Sprintf("No order exists with id %d for this account", orderID))
return nil, nil, ctxerr.Wrap(ctx, err)
}
return nil, nil, ctxerr.Wrap(ctx, err, "select acme order")
}
if err := json.Unmarshal(dbOrder.RawIdentifiers, &dbOrder.Identifiers); err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "unmarshal acme order identifiers")
}
const listAuthorizationsStmt = `SELECT id, acme_order_id, identifier_type, identifier_value, status
FROM acme_authorizations WHERE acme_order_id = ?`
var dbAuthz []struct {
types.Authorization
IdentifierType string `db:"identifier_type"`
IdentifierValue string `db:"identifier_value"`
}
err = sqlx.SelectContext(ctx, ds.reader(ctx), &dbAuthz, listAuthorizationsStmt, orderID)
if err != nil {
return nil, nil, ctxerr.Wrap(ctx, err, "select acme authorizations for order")
}
authorizations := make([]*types.Authorization, len(dbAuthz))
for i, a := range dbAuthz {
authz := a.Authorization
authz.Identifier = types.Identifier{
Type: a.IdentifierType,
Value: a.IdentifierValue,
}
authorizations[i] = &authz
}
return &dbOrder.Order, authorizations, nil
}
func (ds *Datastore) GetCertificatePEMByOrderID(ctx context.Context, accountID, orderID uint) (string, error) {
const getCertStmt = `SELECT certificate_pem
FROM
identity_certificates ic
JOIN acme_orders o ON ic.serial = o.issued_certificate_serial
WHERE
o.acme_account_id = ? AND
o.id = ? AND
ic.revoked IS FALSE`
var certPEM string
err := sqlx.GetContext(ctx, ds.reader(ctx), &certPEM, getCertStmt, accountID, orderID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
err = types.CertificateDoesNotExistError(fmt.Sprintf("No certificate exists for order id %d for this account", orderID))
return "", ctxerr.Wrap(ctx, err)
}
return "", ctxerr.Wrap(ctx, err, "select certificate PEM for order")
}
return certPEM, nil
}
func (ds *Datastore) ListAccountOrderIDs(ctx context.Context, accountID uint) ([]uint, error) {
// must not include orders in status 'invalid'
const listOrderIDsStmt = `SELECT id FROM acme_orders WHERE acme_account_id = ? AND status != 'invalid'`
var ids []uint
err := sqlx.SelectContext(ctx, ds.reader(ctx), &ids, listOrderIDsStmt, accountID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "select acme order ids for account")
}
return ids, nil
}

View file

@ -0,0 +1,544 @@
package mysql
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/jose"
)
func generateTestJWK(t *testing.T) jose.JSONWebKey {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
return jose.JSONWebKey{Key: key.Public()}
}
func TestAccountOrder(t *testing.T) {
tdb := testutils.SetupTestDB(t, "acme_account_order")
ds := NewDatastore(tdb.Conns(), tdb.Logger)
env := &testEnv{TestDB: tdb, ds: ds}
cases := []struct {
name string
fn func(t *testing.T, env *testEnv)
}{
{"CreateNewAccount", testCreateNewAccount},
{"ReturnExistingSameJWK", testReturnExistingSameJWK},
{"OnlyReturnExistingFound", testOnlyReturnExistingFound},
{"OnlyReturnExistingNotFound", testOnlyReturnExistingNotFound},
{"AccountCreationLimit", testAccountCreationLimit},
{"AccountRevoked", testAccountRevoked},
{"InvalidEnrollmentID", testInvalidEnrollmentID},
{"CreateNewOrder", testCreateNewOrder},
{"OrderCreationLimit", testOrderCreationLimit},
{"InvalidAccountID", testInvalidAccountID},
{"MultipleOrdersDifferentAccounts", testMultipleOrdersDifferentAccounts},
{"GetExistingOrder", testGetExistingOrder},
{"OrderNotFound", testGetOrderNotFound},
{"WrongAccountID", testGetOrderWrongAccountID},
{"ListOrderIDs", testListOrderIDs},
{"ListOrderIDsExcludesInvalid", testListOrderIDsExcludesInvalid},
{"ListOrderIDsEmpty", testListOrderIDsEmpty},
{"ListOrderIDsInvalidAccount", testListOrderIDsInvalidAccount},
{"GetCertificatePEM", testGetCertificatePEM},
{"GetCertificatePEMNotFound", testGetCertificatePEMNotFound},
{"GetCertificatePEMRevoked", testGetCertificatePEMRevoked},
{"GetCertificatePEMWrongAccount", testGetCertificatePEMWrongAccount},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer env.TruncateTables(t)
c.fn(t, env)
})
}
}
func testCreateNewAccount(t *testing.T, env *testEnv) {
enrollment := &types.Enrollment{}
env.InsertACMEEnrollment(t, enrollment)
jwk1 := generateTestJWK(t)
account1 := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk1,
}
result1, didCreate, err := env.ds.CreateAccount(t.Context(), account1, false)
require.NoError(t, err)
require.NotZero(t, result1.ID)
require.Equal(t, enrollment.ID, result1.ACMEEnrollmentID)
require.True(t, didCreate)
// verify enrollment's not_valid_after was set
updatedEnrollment1, err := env.ds.GetACMEEnrollment(t.Context(), enrollment.PathIdentifier)
require.NoError(t, err)
require.NotNil(t, updatedEnrollment1.NotValidAfter)
require.True(t, updatedEnrollment1.NotValidAfter.After(time.Now()))
time.Sleep(time.Second) // ensure different timestamp
// create another account
jwk2 := generateTestJWK(t)
account2 := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk2,
}
result2, didCreate, err := env.ds.CreateAccount(t.Context(), account2, false)
require.NoError(t, err)
require.NotZero(t, result2.ID)
require.Equal(t, enrollment.ID, result2.ACMEEnrollmentID)
require.NotEqual(t, result1.ID, result2.ID)
require.True(t, didCreate)
// verify enrollment's not_valid_after was not updated as it was already set
updatedEnrollment2, err := env.ds.GetACMEEnrollment(t.Context(), enrollment.PathIdentifier)
require.NoError(t, err)
require.NotNil(t, updatedEnrollment1.NotValidAfter)
require.True(t, updatedEnrollment1.NotValidAfter.After(time.Now()))
require.True(t, updatedEnrollment1.NotValidAfter.Equal(*updatedEnrollment2.NotValidAfter))
}
func testReturnExistingSameJWK(t *testing.T, env *testEnv) {
enrollment := &types.Enrollment{}
env.InsertACMEEnrollment(t, enrollment)
jwk := generateTestJWK(t)
account1 := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
result1, didCreate, err := env.ds.CreateAccount(t.Context(), account1, false)
require.NoError(t, err)
require.NotNil(t, result1)
require.True(t, didCreate)
// create again with same JWK
account2 := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
result2, didCreate, err := env.ds.CreateAccount(t.Context(), account2, false)
require.NoError(t, err)
require.NotNil(t, result2)
require.Equal(t, result1.ID, result2.ID)
require.False(t, didCreate)
}
func testOnlyReturnExistingFound(t *testing.T, env *testEnv) {
enrollment := &types.Enrollment{}
env.InsertACMEEnrollment(t, enrollment)
jwk := generateTestJWK(t)
// create the account first
account := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
created, didCreate, err := env.ds.CreateAccount(t.Context(), account, false)
require.NoError(t, err)
require.True(t, didCreate)
// now look it up with onlyReturnExisting=true
lookup := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
found, didCreate, err := env.ds.CreateAccount(t.Context(), lookup, true)
require.NoError(t, err)
require.NotNil(t, found)
require.Equal(t, created.ID, found.ID)
require.False(t, didCreate)
}
func testOnlyReturnExistingNotFound(t *testing.T, env *testEnv) {
enrollment := &types.Enrollment{}
env.InsertACMEEnrollment(t, enrollment)
jwk := generateTestJWK(t)
account := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
result, didCreate, err := env.ds.CreateAccount(t.Context(), account, true)
require.Nil(t, result)
require.Error(t, err)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "error:accountDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
require.False(t, didCreate)
}
func testAccountCreationLimit(t *testing.T, env *testEnv) {
enrollment := &types.Enrollment{}
env.InsertACMEEnrollment(t, enrollment)
// create 3 accounts (the max)
for range maxAccountsPerEnrollment {
jwk := generateTestJWK(t)
account := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
_, _, err := env.ds.CreateAccount(t.Context(), account, false)
require.NoError(t, err)
}
// 4th should fail
jwk := generateTestJWK(t)
account := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
result, _, err := env.ds.CreateAccount(t.Context(), account, false)
require.Nil(t, result)
require.Error(t, err)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "error/tooManyAccounts") // nolint:nilaway // cannot be nil due to previous require
}
func testAccountRevoked(t *testing.T, env *testEnv) {
enrollment := &types.Enrollment{}
env.InsertACMEEnrollment(t, enrollment)
jwk := generateTestJWK(t)
account := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
created, didCreate, err := env.ds.CreateAccount(t.Context(), account, false)
require.NoError(t, err)
require.NotNil(t, created)
require.True(t, didCreate)
// revoke the account directly in the DB
_, err = env.DB.ExecContext(t.Context(), `UPDATE acme_accounts SET revoked = 1 WHERE id = ?`, created.ID)
require.NoError(t, err)
// try to create again with the same JWK — should get accountRevoked error
account2 := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
_, _, err = env.ds.CreateAccount(t.Context(), account2, false)
require.Error(t, err)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "error/accountRevoked") // nolint:nilaway // cannot be nil due to previous require
}
func testInvalidEnrollmentID(t *testing.T, env *testEnv) {
jwk := generateTestJWK(t)
account := &types.Account{
ACMEEnrollmentID: 99999,
JSONWebKey: jwk,
}
result, _, err := env.ds.CreateAccount(t.Context(), account, false)
require.Nil(t, result)
require.Error(t, err)
}
// createTestAccountForOrder is a helper that creates an enrollment and account for order tests.
func createTestAccountForOrder(t *testing.T, env *testEnv) (*types.Account, *types.Enrollment) {
t.Helper()
enrollment := &types.Enrollment{}
env.InsertACMEEnrollment(t, enrollment)
jwk := generateTestJWK(t)
account := &types.Account{
ACMEEnrollmentID: enrollment.ID,
JSONWebKey: jwk,
}
created, _, err := env.ds.CreateAccount(t.Context(), account, false)
require.NoError(t, err)
return created, enrollment
}
func buildTestOrder(accountID uint, identifierValue string) (*types.Order, *types.Authorization, *types.Challenge) {
return &types.Order{
ACMEAccountID: accountID,
Status: types.OrderStatusPending,
Identifiers: []types.Identifier{
{Type: types.IdentifierTypePermanentIdentifier, Value: identifierValue},
},
}, &types.Authorization{
Identifier: types.Identifier{Type: types.IdentifierTypePermanentIdentifier, Value: identifierValue},
Status: types.AuthorizationStatusPending,
}, &types.Challenge{
ChallengeType: types.DeviceAttestationChallengeType,
Token: "test-token",
Status: types.ChallengeStatusPending,
}
}
func testCreateNewOrder(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
require.NotZero(t, result.ID)
require.Equal(t, account.ID, result.ACMEAccountID)
require.Equal(t, types.OrderStatusPending, result.Status)
// authorization and challenge IDs should be set by CreateOrder
require.NotZero(t, authorization.ID)
require.NotZero(t, challenge.ID)
}
func testOrderCreationLimit(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
// create maxOrdersPerAccount orders (the max)
for range maxOrdersPerAccount {
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
_, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
}
// the next order should fail
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.Nil(t, result)
require.Error(t, err)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "error/tooManyOrders") // nolint:nilaway // cannot be nil due to previous require
}
func testInvalidAccountID(t *testing.T, env *testEnv) {
order, authorization, challenge := buildTestOrder(99999, "serial-123")
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.Nil(t, result)
require.Error(t, err)
}
func testMultipleOrdersDifferentAccounts(t *testing.T, env *testEnv) {
account1, _ := createTestAccountForOrder(t, env)
account2, _ := createTestAccountForOrder(t, env)
// create max orders for account1
for range maxOrdersPerAccount {
order, authorization, challenge := buildTestOrder(account1.ID, "serial-123")
_, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
}
// account2 should still be able to create orders independently
order, authorization, challenge := buildTestOrder(account2.ID, "serial-456")
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
require.NotZero(t, result.ID)
require.Equal(t, account2.ID, result.ACMEAccountID)
}
func testGetExistingOrder(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
gotOrder, gotAuths, err := env.ds.GetOrderByID(t.Context(), account.ID, created.ID)
require.NoError(t, err)
require.Equal(t, created.ID, gotOrder.ID)
require.Equal(t, account.ID, gotOrder.ACMEAccountID)
require.Equal(t, types.OrderStatusPending, gotOrder.Status)
require.False(t, gotOrder.Finalized)
require.Len(t, gotOrder.Identifiers, 1)
require.Equal(t, types.IdentifierTypePermanentIdentifier, gotOrder.Identifiers[0].Type)
require.Equal(t, "serial-123", gotOrder.Identifiers[0].Value)
require.Len(t, gotAuths, 1)
require.Equal(t, authorization.ID, gotAuths[0].ID)
require.Equal(t, authorization.Identifier, gotAuths[0].Identifier)
}
func testGetOrderNotFound(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
_, _, err := env.ds.GetOrderByID(t.Context(), account.ID, 99999)
require.Error(t, err)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "orderDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
}
func testGetOrderWrongAccountID(t *testing.T, env *testEnv) {
account1, _ := createTestAccountForOrder(t, env)
account2, _ := createTestAccountForOrder(t, env)
order, authorization, challenge := buildTestOrder(account1.ID, "serial-123")
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
// try to get the order using account2's ID — should fail
_, _, err = env.ds.GetOrderByID(t.Context(), account2.ID, created.ID)
require.Error(t, err)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "orderDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
}
func testListOrderIDs(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
// create a couple of orders
var expectedIDs []uint
for range 2 {
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
expectedIDs = append(expectedIDs, created.ID)
}
ids, err := env.ds.ListAccountOrderIDs(t.Context(), account.ID)
require.NoError(t, err)
require.Equal(t, expectedIDs, ids)
}
func testListOrderIDsExcludesInvalid(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
// create two orders
allIDs := make([]uint, 0, 2)
for range cap(allIDs) {
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
allIDs = append(allIDs, created.ID)
}
// mark the first order as invalid directly in the DB
_, err := env.DB.ExecContext(t.Context(), `UPDATE acme_orders SET status = 'invalid' WHERE id = ?`, allIDs[0])
require.NoError(t, err)
ids, err := env.ds.ListAccountOrderIDs(t.Context(), account.ID)
require.NoError(t, err)
require.Equal(t, []uint{allIDs[1]}, ids)
}
func testListOrderIDsEmpty(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
ids, err := env.ds.ListAccountOrderIDs(t.Context(), account.ID)
require.NoError(t, err)
require.Empty(t, ids)
}
func testListOrderIDsInvalidAccount(t *testing.T, env *testEnv) {
ids, err := env.ds.ListAccountOrderIDs(t.Context(), 99999)
require.NoError(t, err)
require.Empty(t, ids)
}
// insertTestCertificate inserts an identity serial and certificate into the database for testing.
func insertTestCertificate(t *testing.T, env *testEnv, serial uint64, certPEM string, revoked bool) {
t.Helper()
ctx := t.Context()
_, err := env.DB.ExecContext(ctx, `INSERT INTO identity_serials (serial) VALUES (?)`, serial)
require.NoError(t, err)
_, err = env.DB.ExecContext(ctx, `
INSERT INTO identity_certificates (serial, not_valid_before, not_valid_after, certificate_pem, revoked)
VALUES (?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), ?, ?)
`, serial, certPEM, revoked)
require.NoError(t, err)
}
func testGetCertificatePEM(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
// insert a certificate and link it to the order
var certSerial uint64 = 1001
expectedPEM := "-----BEGIN CERTIFICATE-----\ntest-cert-pem\n-----END CERTIFICATE-----"
insertTestCertificate(t, env, certSerial, expectedPEM, false)
_, err = env.DB.ExecContext(t.Context(), `UPDATE acme_orders SET issued_certificate_serial = ? WHERE id = ?`, certSerial, created.ID)
require.NoError(t, err)
gotPEM, err := env.ds.GetCertificatePEMByOrderID(t.Context(), account.ID, created.ID)
require.NoError(t, err)
require.Equal(t, expectedPEM, gotPEM)
}
func testGetCertificatePEMNotFound(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
// create an order without linking a certificate
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
_, err = env.ds.GetCertificatePEMByOrderID(t.Context(), account.ID, created.ID)
require.Error(t, err)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "certificateDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
}
func testGetCertificatePEMRevoked(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
// insert a revoked certificate and link it to the order
var certSerial uint64 = 2001
insertTestCertificate(t, env, certSerial, "-----BEGIN CERTIFICATE-----\nrevoked\n-----END CERTIFICATE-----", true)
_, err = env.DB.ExecContext(t.Context(), `UPDATE acme_orders SET issued_certificate_serial = ? WHERE id = ?`, certSerial, created.ID)
require.NoError(t, err)
_, err = env.ds.GetCertificatePEMByOrderID(t.Context(), account.ID, created.ID)
require.Error(t, err)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "certificateDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
}
func testGetCertificatePEMWrongAccount(t *testing.T, env *testEnv) {
account1, _ := createTestAccountForOrder(t, env)
account2, _ := createTestAccountForOrder(t, env)
order, authorization, challenge := buildTestOrder(account1.ID, "serial-123")
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
// insert a certificate and link it to account1's order
var certSerial uint64 = 3001
insertTestCertificate(t, env, certSerial, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", false)
_, err = env.DB.ExecContext(t.Context(), `UPDATE acme_orders SET issued_certificate_serial = ? WHERE id = ?`, certSerial, created.ID)
require.NoError(t, err)
// try to get the certificate using account2's ID — should fail
_, err = env.ds.GetCertificatePEMByOrderID(t.Context(), account2.ID, created.ID)
require.Error(t, err)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "certificateDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
}

View file

@ -0,0 +1,42 @@
// Package mysql provides the MySQL datastore implementation for the ACME service module.
package mysql
import (
"context"
"log/slog"
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/jmoiron/sqlx"
"go.opentelemetry.io/otel"
)
// tracer is an OTEL tracer. It has no-op behavior when OTEL is not enabled.
var tracer = otel.Tracer("github.com/fleetdm/fleet/v4/server/mdm/acme/internal/mysql")
// Datastore is the MySQL implementation of the ACME datastore.
type Datastore struct {
primary *sqlx.DB
replica *sqlx.DB
logger *slog.Logger
}
// NewDatastore creates a new MySQL datastore for activities.
func NewDatastore(conns *platform_mysql.DBConnections, logger *slog.Logger) *Datastore {
return &Datastore{primary: conns.Primary, replica: conns.Replica, logger: logger}
}
func (ds *Datastore) reader(ctx context.Context) sqlx.QueryerContext {
if ctxdb.IsPrimaryRequired(ctx) {
return ds.primary
}
return ds.replica
}
func (ds *Datastore) writer(_ context.Context) *sqlx.DB {
return ds.primary
}
// Ensure Datastore implements types.Datastore
var _ types.Datastore = (*Datastore)(nil)

View file

@ -0,0 +1,9 @@
package mysql
import "github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
// testEnv holds test dependencies.
type testEnv struct {
*testutils.TestDB
ds *Datastore
}

View file

@ -0,0 +1,62 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/jmoiron/sqlx"
)
// This file does not handle normal authentication, but the ACME concept of authorization as part of the protocol.
func (ds *Datastore) GetAuthorizationByID(ctx context.Context, accountID uint, authorizationID uint) (*types.Authorization, error) {
if accountID == 0 {
return nil, types.MalformedError("invalid account ID")
}
if authorizationID == 0 {
return nil, types.MalformedError("invalid authorization ID")
}
var dbAuthz struct {
types.Authorization
IdentifierType string `db:"identifier_type"`
IdentifierValue string `db:"identifier_value"`
}
const query = `SELECT a.id, a.acme_order_id, a.identifier_type, a.identifier_value, a.status FROM acme_authorizations a
INNER JOIN acme_orders o ON a.acme_order_id = o.id
INNER JOIN acme_accounts ac ON o.acme_account_id = ac.id
WHERE a.id = ? AND ac.id = ?`
err := sqlx.GetContext(ctx, ds.reader(ctx), &dbAuthz, query, authorizationID, accountID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, types.AuthorizationDoesNotExistError(fmt.Sprintf("ACME authorization with ID %d not found for account ID %d", authorizationID, accountID))
}
return nil, err
}
return &types.Authorization{
ID: dbAuthz.ID,
ACMEOrderID: dbAuthz.ACMEOrderID,
Identifier: types.Identifier{
Type: dbAuthz.IdentifierType,
Value: dbAuthz.IdentifierValue,
},
Status: dbAuthz.Status,
}, nil
}
func (ds *Datastore) GetAuthorizationsByOrderID(ctx context.Context, orderID uint) ([]*types.Authorization, error) {
const stmt = `SELECT id, acme_order_id, identifier_type, identifier_value, status FROM acme_authorizations WHERE acme_order_id = ?`
var authorizations []*types.Authorization
err := ds.primary.SelectContext(ctx, &authorizations, stmt, orderID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get acme authorizations for order")
}
return authorizations, nil
}

View file

@ -0,0 +1,88 @@
package mysql
import (
"testing"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/stretchr/testify/require"
)
func TestACMEAuthorization(t *testing.T) {
tdb := testutils.SetupTestDB(t, "acme_authorization")
ds := NewDatastore(tdb.Conns(), tdb.Logger)
env := &testEnv{TestDB: tdb, ds: ds}
cases := []struct {
name string
fn func(t *testing.T, env *testEnv)
}{
{"GetValidAuthorization", testGetValidAuthorization},
{"GetAuthorizationWithInvalidID", testGetAuthorizationWithInvalidID},
{"GetAuthorizationWithInvalidAccountID", testGetAuthorizationWithInvalidAccountID},
{"GetAuthorizationWithInvalidInputs", testGetAuthorizationWithInvalidInputs},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer env.TruncateTables(t)
c.fn(t, env)
})
}
}
func testGetValidAuthorization(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
order, authorization, _ := createTestOrderForAccount(t, account, env)
authResp, err := env.ds.GetAuthorizationByID(t.Context(), account.ID, authorization.ID)
require.NoError(t, err)
require.NotNil(t, authResp)
require.Equal(t, types.AuthorizationStatusPending, authResp.Status)
require.Equal(t, "permanent-identifier", authResp.Identifier.Type)
require.Equal(t, "serial-123", authResp.Identifier.Value)
require.Equal(t, order.ID, authResp.ACMEOrderID)
require.Equal(t, authorization.ID, authResp.ID)
}
func testGetAuthorizationWithInvalidID(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
authResp, err := env.ds.GetAuthorizationByID(t.Context(), account.ID, 9999) // non-existent ID
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "error/authorizationDoesNotExist") //nolint:nilaway // cannot be null due to previous require
require.Nil(t, authResp)
}
func testGetAuthorizationWithInvalidAccountID(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
_, authorization, _ := createTestOrderForAccount(t, account, env)
authResp, err := env.ds.GetAuthorizationByID(t.Context(), 9999, authorization.ID) // non-existent account ID
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "error/authorizationDoesNotExist") //nolint:nilaway // cannot be null due to previous require
require.Nil(t, authResp)
}
func testGetAuthorizationWithInvalidInputs(t *testing.T, env *testEnv) {
authResp, err := env.ds.GetAuthorizationByID(t.Context(), 0, 0) // zero account ID
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "malformed") //nolint:nilaway // cannot be null due to previous require
require.Nil(t, authResp)
authResp, err = env.ds.GetAuthorizationByID(t.Context(), 1, 0) // zero authorization ID
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "malformed") //nolint:nilaway // cannot be null due to previous require
require.Nil(t, authResp)
}
func createTestOrderForAccount(t *testing.T, account *types.Account, env *testEnv) (*types.Order, *types.Authorization, *types.Challenge) {
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
require.NoError(t, err)
return result, authorization, challenge
}

View file

@ -0,0 +1,107 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/jmoiron/sqlx"
)
func (ds *Datastore) GetChallengesByAuthorizationID(ctx context.Context, authorizationID uint) ([]*types.Challenge, error) {
if authorizationID == 0 {
return nil, types.MalformedError("invalid authorization ID")
}
const query = `SELECT id, acme_authorization_id, challenge_type, status, token, updated_at FROM acme_challenges WHERE acme_authorization_id = ?`
var challenges []*types.Challenge
err := sqlx.SelectContext(ctx, ds.reader(ctx), &challenges, query, authorizationID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting challenges by authorization ID")
}
if len(challenges) == 0 {
return nil, types.ChallengeDoesNotExistError(fmt.Sprintf("No challenges found for authorization ID %d", authorizationID))
}
return challenges, nil
}
// We require the accountID to validate the challenge belongs to the account trying to validate it
func (ds *Datastore) GetChallengeByID(ctx context.Context, accountID, challengeID uint) (*types.Challenge, error) {
if challengeID == 0 {
return nil, types.MalformedError("invalid challenge ID")
}
const query = `SELECT ac.id, ac.acme_authorization_id, ac.challenge_type, ac.status, ac.token, ac.updated_at FROM acme_challenges ac
INNER JOIN acme_authorizations a ON ac.acme_authorization_id = a.id
INNER JOIN acme_orders o ON a.acme_order_id = o.id
WHERE ac.id = ? AND o.acme_account_id = ?`
var challenge types.Challenge
err := sqlx.GetContext(ctx, ds.reader(ctx), &challenge, query, challengeID, accountID)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, types.ChallengeDoesNotExistError(fmt.Sprintf("Challenge with ID %d not found for account ID %d", challengeID, accountID))
}
return nil, ctxerr.Wrap(ctx, err, "getting challenge by ID")
}
return &challenge, nil
}
// UpdateChallenge handles updating the challenge status, and the authorization status as well as moving the order status.
func (ds *Datastore) UpdateChallenge(ctx context.Context, challenge *types.Challenge) (*types.Challenge, error) {
if challenge == nil {
return nil, errors.New("Challenge can not be nil for update")
}
err := platform_mysql.WithRetryTxx(ctx, ds.writer(ctx), func(tx sqlx.ExtContext) error {
const updateChallengeStmt = `UPDATE acme_challenges SET status = ? WHERE id = ?`
_, err := tx.ExecContext(ctx, updateChallengeStmt, challenge.Status, challenge.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating challenge")
}
const updateAuthorizationStatusStmt = `UPDATE acme_authorizations a INNER JOIN acme_challenges c ON a.id = c.acme_authorization_id
SET a.status = CASE
WHEN c.status = 'valid' THEN 'valid'
ELSE 'invalid'
END
WHERE c.id = ?`
_, err = tx.ExecContext(ctx, updateAuthorizationStatusStmt, challenge.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating authorization status based on challenge status")
}
// We can confidently update the order status here based on the challenge and authorization status
// since we currently only have one, if we ever add more the state machine should account for all authorizations
// to be valid before moving the order to ready
const updateOrderStatusStmt = `UPDATE acme_orders o INNER JOIN acme_authorizations a ON o.id = a.acme_order_id
SET o.status = CASE
WHEN ? = 'valid' THEN 'ready'
WHEN ? = 'invalid' THEN 'invalid'
ELSE o.status
END
WHERE a.id = ? AND o.status != 'invalid'`
_, err = tx.ExecContext(ctx, updateOrderStatusStmt, challenge.Status, challenge.Status, challenge.ACMEAuthorizationID)
if err != nil {
return ctxerr.Wrap(ctx, err, "updating order status based on challenge status")
}
const selectQuery = `SELECT id, acme_authorization_id, challenge_type, status, token, updated_at FROM acme_challenges WHERE id = ?`
err = sqlx.GetContext(ctx, tx, challenge, selectQuery, challenge.ID)
if err != nil {
return ctxerr.Wrap(ctx, err, "getting updated challenge")
}
return nil
}, ds.logger)
if err != nil {
return nil, err
}
return challenge, nil
}

View file

@ -0,0 +1,178 @@
package mysql
import (
"testing"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/stretchr/testify/require"
)
func TestACMEChallenge(t *testing.T) {
tdb := testutils.SetupTestDB(t, "acme_challenge")
ds := NewDatastore(tdb.Conns(), tdb.Logger)
env := &testEnv{TestDB: tdb, ds: ds}
cases := []struct {
name string
fn func(t *testing.T, env *testEnv)
}{
{"GetValidChallengesForAuthorization", testGetValidChallengesForAuthorization},
{"NoChallengesForAuthorization", testGetChallengesWithNoChallengesForAuthorization},
{"GetChallengesWithInvalidAuthorizationID", testGetChallengesWithInvalidAuthorizationID},
{"GetChallengesWithZeroAuthorizationID", testGetChallengesWithZeroAuthorizationID},
{"GetChallengeByIDWithValidID", testGetChallengeByIDWithValidID},
{"GetChallengeByIDWithInvalidID", testGetChallengeByIDWithInvalidID},
{"GetChallengeByIDWithInvalidAccountID", testGetChallengeByIDWithInvalidAccountID},
{"UpdateChallengeHappyPath", testUpdateChallengeHappyPath},
{"UpdateChallengeInvalidStatus", testUpdateChallengeInvalidStatus},
{"UpdateChallengeNilChallenge", testUpdateChallengeNilChallenge},
{"UpdateChallengeNonExistentID", testUpdateChallengeNonExistentID},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer env.TruncateTables(t)
c.fn(t, env)
})
}
}
func testGetValidChallengesForAuthorization(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
_, authorization, _ := createTestOrderForAccount(t, account, env)
challenges, err := env.ds.GetChallengesByAuthorizationID(t.Context(), authorization.ID)
require.NoError(t, err)
require.Len(t, challenges, 1)
require.Equal(t, types.ChallengeStatusPending, challenges[0].Status)
require.Equal(t, types.DeviceAttestationChallengeType, challenges[0].ChallengeType)
require.Equal(t, authorization.ID, challenges[0].ACMEAuthorizationID)
}
func testGetChallengesWithNoChallengesForAuthorization(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
_, authorization, _ := createTestOrderForAccount(t, account, env)
// Delete the challenge to simulate no challenges for the authorization
_, err := env.TestDB.DB.ExecContext(t.Context(), "DELETE FROM acme_challenges WHERE acme_authorization_id = ?", authorization.ID)
require.NoError(t, err)
challenges, err := env.ds.GetChallengesByAuthorizationID(t.Context(), authorization.ID)
var acmeError *types.ACMEError
require.ErrorAs(t, err, &acmeError)
require.Contains(t, acmeError.Type, "error/challengeDoesNotExist") //nolint:nilaway // cannot be null due to previous require
require.Nil(t, challenges)
}
func testGetChallengesWithInvalidAuthorizationID(t *testing.T, env *testEnv) {
challenges, err := env.ds.GetChallengesByAuthorizationID(t.Context(), 999999) // non-existent ID
var acmeError *types.ACMEError
require.ErrorAs(t, err, &acmeError)
require.Contains(t, acmeError.Type, "error/challengeDoesNotExist") //nolint:nilaway // cannot be null due to previous require
require.Nil(t, challenges)
}
func testGetChallengesWithZeroAuthorizationID(t *testing.T, env *testEnv) {
challenges, err := env.ds.GetChallengesByAuthorizationID(t.Context(), 0)
var acmeError *types.ACMEError
require.ErrorAs(t, err, &acmeError)
require.Contains(t, acmeError.Type, "malformed") //nolint:nilaway // cannot be null due to previous require
require.Nil(t, challenges)
}
func testGetChallengeByIDWithValidID(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
_, authorization, challenge := createTestOrderForAccount(t, account, env)
challenge, err := env.ds.GetChallengeByID(t.Context(), account.ID, challenge.ID)
require.NoError(t, err)
require.NotNil(t, challenge)
require.Equal(t, types.ChallengeStatusPending, challenge.Status)
require.Equal(t, types.DeviceAttestationChallengeType, challenge.ChallengeType)
require.Equal(t, authorization.ID, challenge.ACMEAuthorizationID)
}
func testGetChallengeByIDWithInvalidID(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
challenge, err := env.ds.GetChallengeByID(t.Context(), account.ID, 9999) // non-existent ID
var acmeError *types.ACMEError
require.ErrorAs(t, err, &acmeError)
require.Contains(t, acmeError.Type, "error/challengeDoesNotExist") //nolint:nilaway // cannot be null due to previous require
require.Nil(t, challenge)
}
func testGetChallengeByIDWithInvalidAccountID(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
_, _, challenge := createTestOrderForAccount(t, account, env)
challenge, err := env.ds.GetChallengeByID(t.Context(), 9999, challenge.ID) // non-existent account ID
var acmeError *types.ACMEError
require.ErrorAs(t, err, &acmeError)
require.Contains(t, acmeError.Type, "error/challengeDoesNotExist") //nolint:nilaway // cannot be null due to previous require
require.Nil(t, challenge)
}
func testUpdateChallengeHappyPath(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
order, _, challenge := createTestOrderForAccount(t, account, env)
challenge.Status = types.ChallengeStatusValid
updatedChallenge, err := env.ds.UpdateChallenge(t.Context(), challenge)
require.NoError(t, err)
require.NotNil(t, updatedChallenge)
require.Equal(t, types.ChallengeStatusValid, updatedChallenge.Status)
require.Equal(t, challenge.ID, updatedChallenge.ID)
order, auhtz, err := env.ds.GetOrderByID(t.Context(), account.ID, order.ID)
require.NoError(t, err)
require.NotNil(t, auhtz)
for _, auth := range auhtz {
require.Equal(t, types.AuthorizationStatusValid, auth.Status)
}
require.Equal(t, types.OrderStatusReady, order.Status)
}
func testUpdateChallengeInvalidStatus(t *testing.T, env *testEnv) {
account, _ := createTestAccountForOrder(t, env)
order, _, challenge := createTestOrderForAccount(t, account, env)
challenge.Status = types.ChallengeStatusInvalid
updatedChallenge, err := env.ds.UpdateChallenge(t.Context(), challenge)
require.NoError(t, err)
require.NotNil(t, updatedChallenge)
require.Equal(t, types.ChallengeStatusInvalid, updatedChallenge.Status)
order, auhtz, err := env.ds.GetOrderByID(t.Context(), account.ID, order.ID)
require.NoError(t, err)
require.NotNil(t, auhtz)
for _, auth := range auhtz {
require.Equal(t, types.AuthorizationStatusInvalid, auth.Status)
}
require.Equal(t, types.OrderStatusInvalid, order.Status)
}
func testUpdateChallengeNilChallenge(t *testing.T, env *testEnv) {
updatedChallenge, err := env.ds.UpdateChallenge(t.Context(), nil)
require.Error(t, err)
require.Contains(t, err.Error(), "Challenge can not be nil for update")
require.Nil(t, updatedChallenge)
}
func testUpdateChallengeNonExistentID(t *testing.T, env *testEnv) {
challenge := &types.Challenge{
ID: 999999,
ACMEAuthorizationID: 999999,
Status: types.ChallengeStatusValid,
ChallengeType: types.DeviceAttestationChallengeType,
}
updatedChallenge, err := env.ds.UpdateChallenge(t.Context(), challenge)
require.Error(t, err)
require.Nil(t, updatedChallenge)
}

View file

@ -0,0 +1,41 @@
package mysql
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/jmoiron/sqlx"
)
func (ds *Datastore) GetACMEEnrollment(ctx context.Context, pathIdentifier string) (*types.Enrollment, error) {
ctx, span := tracer.Start(ctx, "acme.mysql.GetACMEEnrollment")
defer span.End()
const stmt = `
SELECT
id,
path_identifier,
host_identifier,
not_valid_after,
revoked
FROM
acme_enrollments
WHERE
path_identifier = ?
LIMIT 1
`
var enrollment types.Enrollment
err := sqlx.GetContext(ctx, ds.reader(ctx), &enrollment, stmt, pathIdentifier)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
err = types.EnrollmentNotFoundError(fmt.Sprintf("ACME enrollment with path identifier %s not found", pathIdentifier))
return nil, ctxerr.Wrap(ctx, err)
}
return nil, ctxerr.Wrap(ctx, err, "getting ACME enrollment")
}
return &enrollment, nil
}

View file

@ -0,0 +1,71 @@
package mysql
import (
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/require"
)
func TestDirectoryNonce(t *testing.T) {
tdb := testutils.SetupTestDB(t, "acme_directory_nonce")
ds := NewDatastore(tdb.Conns(), tdb.Logger)
env := &testEnv{TestDB: tdb, ds: ds}
cases := []struct {
name string
fn func(t *testing.T, env *testEnv)
}{
{"GetACMEEnrollment", testGetACMEEnrollment},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer env.TruncateTables(t)
c.fn(t, env)
})
}
}
func testGetACMEEnrollment(t *testing.T, env *testEnv) {
// non-existing
enrollment, err := env.ds.GetACMEEnrollment(t.Context(), "non-existing")
require.Nil(t, enrollment)
var acmeErr *types.ACMEError
require.ErrorAs(t, err, &acmeErr)
require.Contains(t, acmeErr.Type, "error/enrollmentNotFound") // nolint:nilaway // cannot be nil due to previous require
// existing and valid
enrollValid := &types.Enrollment{}
env.InsertACMEEnrollment(t, enrollValid)
enrollment, err = env.ds.GetACMEEnrollment(t.Context(), enrollValid.PathIdentifier)
require.NoError(t, err)
require.NotNil(t, enrollment)
require.True(t, enrollment.IsValid())
// existing and revoked
enrollRevoked := &types.Enrollment{Revoked: true}
env.InsertACMEEnrollment(t, enrollRevoked)
enrollment, err = env.ds.GetACMEEnrollment(t.Context(), enrollRevoked.PathIdentifier)
require.NoError(t, err)
require.NotNil(t, enrollment)
require.False(t, enrollment.IsValid())
// existing and not-valid-after in the future
enrollFuture := &types.Enrollment{NotValidAfter: ptr.T(time.Now().Add(24 * time.Hour))}
env.InsertACMEEnrollment(t, enrollFuture)
enrollment, err = env.ds.GetACMEEnrollment(t.Context(), enrollFuture.PathIdentifier)
require.NoError(t, err)
require.NotNil(t, enrollment)
require.True(t, enrollment.IsValid())
// existing and not-valid-after in the past
enrollPast := &types.Enrollment{NotValidAfter: ptr.T(time.Now().Add(-24 * time.Hour))}
env.InsertACMEEnrollment(t, enrollPast)
enrollment, err = env.ds.GetACMEEnrollment(t.Context(), enrollPast.PathIdentifier)
require.NoError(t, err)
require.NotNil(t, enrollment)
require.False(t, enrollment.IsValid())
}

View file

@ -0,0 +1,28 @@
package mysql
import (
"context"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/google/uuid"
)
// NewEnrollment creates a new row in the acme_enrollments table with the given
// host_identifier. It generates a new path_identifier for the row and returns
// it.
func (ds *Datastore) NewEnrollment(ctx context.Context, hostIdentifier string) (string, error) {
ctx, span := tracer.Start(ctx, "acme.mysql.NewEnrollment")
defer span.End()
pathIdentifier := uuid.NewString()
_, err := ds.writer(ctx).ExecContext(ctx, `
INSERT INTO acme_enrollments (path_identifier, host_identifier)
VALUES (?, ?)
`, pathIdentifier, hostIdentifier)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "inserting ACME enrollment")
}
return pathIdentifier, nil
}

View file

@ -0,0 +1,59 @@
package redis_nonces_store
import (
"context"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/datastore/redis"
"github.com/fleetdm/fleet/v4/server/mdm/acme"
redigo "github.com/gomodule/redigo/redis"
)
const DefaultNonceExpiration = 1 * time.Hour
// RedisNoncesStore is a store for ACME nonces, implemented using Redis.
type RedisNoncesStore struct {
pool acme.RedisPool
testPrefix string // for tests, the key prefix to use to avoid conflicts
}
// New creates a new RedisNoncesStore store.
func New(pool acme.RedisPool) *RedisNoncesStore {
return &RedisNoncesStore{pool: pool}
}
// prefix is used to not collide with other key domains (like live queries or calendar locks).
const prefix = "acmenonce:"
// Store creates the key with the given nonce.
// Argument expireTime is used to set the expiration of the item.
func (r *RedisNoncesStore) Store(ctx context.Context, nonce string, expireTime time.Duration) error {
conn := redis.ConfigureDoer(r.pool, r.pool.Get())
defer conn.Close()
// the value of the key is not really important, just that the key exists or not (indicates
// that the nonce is valid or not), so we set the value to be the same as the nonce.
if _, err := redigo.String(conn.Do("SET", r.testPrefix+prefix+nonce, nonce, "PX", expireTime.Milliseconds())); err != nil {
return ctxerr.Wrap(ctx, err, "redis failed to set")
}
return nil
}
// Consume validates and consumes the nonce, ensuring it does exist, and then removing
// it from the store so it can't be used again.
func (r *RedisNoncesStore) Consume(ctx context.Context, nonce string) (ok bool, err error) {
// fast path if the nonce is missing
if nonce == "" {
return false, nil
}
conn := redis.ConfigureDoer(r.pool, r.pool.Get())
defer conn.Close()
n, err := redigo.Int(conn.Do("DEL", r.testPrefix+prefix+nonce))
if err != nil {
return false, ctxerr.Wrap(ctx, err, "redis failed to delete")
}
return n > 0, nil
}

View file

@ -0,0 +1,69 @@
package redis_nonces_store
import (
"context"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/mdm/acme"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/require"
)
func TestRedisNoncesStore(t *testing.T) {
for _, f := range []func(*testing.T, *RedisNoncesStore){
testStoreConsume,
} {
t.Run(test.FunctionName(f), func(t *testing.T) {
t.Run("standalone", func(t *testing.T) {
kv := setupRedis(t, false, false)
f(t, kv)
})
t.Run("cluster", func(t *testing.T) {
kv := setupRedis(t, true, true)
f(t, kv)
})
})
}
}
func setupRedis(t testing.TB, cluster, redir bool) *RedisNoncesStore {
pool := redistest.SetupRedis(t, t.Name(), cluster, redir, true)
return newRedisNoncesStoreForTest(t, pool)
}
type testName interface {
Name() string
}
func newRedisNoncesStoreForTest(t testName, pool acme.RedisPool) *RedisNoncesStore {
return &RedisNoncesStore{
pool: pool,
testPrefix: t.Name() + ":",
}
}
func testStoreConsume(t *testing.T, store *RedisNoncesStore) {
ctx := context.Background()
err := store.Store(ctx, "foo", time.Millisecond)
require.NoError(t, err)
err = store.Store(ctx, "bar", 5*time.Second)
require.NoError(t, err)
ok, err := store.Consume(ctx, "bar")
require.NoError(t, err)
require.True(t, ok)
time.Sleep(2 * time.Millisecond)
ok, err = store.Consume(ctx, "foo")
require.NoError(t, err)
require.False(t, ok)
ok, err = store.Consume(ctx, "no-such")
require.NoError(t, err)
require.False(t, ok)
}

View file

@ -0,0 +1,297 @@
package service
import (
"context"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"go.step.sm/crypto/jose"
)
func (s *Service) CreateAccount(ctx context.Context, pathIdentifier string, enrollmentID uint, jwk jose.JSONWebKey, onlyReturnExisting bool) (*types.AccountResponse, error) {
// authorization is checked in the endpoint implementation for JWS-protected endpoints
account := &types.Account{
ACMEEnrollmentID: enrollmentID,
JSONWebKey: jwk,
}
account, didCreate, err := s.store.CreateAccount(ctx, account, onlyReturnExisting)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating account in datastore")
}
baseURL, err := s.getACMEBaseURL(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
}
ordersURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, pathIdentifier, "accounts", fmt.Sprint(account.ID), "orders")
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing orders URL for account")
}
acctURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, pathIdentifier, "accounts", fmt.Sprint(account.ID))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing account URL for account")
}
return &types.AccountResponse{
CreatedAccount: account,
DidCreate: didCreate,
Status: "valid", // for now, in our implementation, always valid
Orders: ordersURL,
Location: acctURL,
}, nil
}
func (s *Service) CreateOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, partialOrder *types.Order) (*types.OrderResponse, error) {
// authorization is checked in the endpoint implementation for JWS-protected endpoints
if err := partialOrder.ValidateOrderCreation(enrollment); err != nil {
return nil, err
}
identifiers := []types.Identifier{
{Type: partialOrder.Identifiers[0].Type, Value: partialOrder.Identifiers[0].Value},
}
order := &types.Order{
ACMEAccountID: account.ID,
Finalized: false,
Identifiers: identifiers,
Status: types.OrderStatusPending, // always pending at creation
}
authz := &types.Authorization{
Identifier: identifiers[0],
Status: types.AuthorizationStatusPending, // always pending at creation
}
challenge := &types.Challenge{
ChallengeType: types.DeviceAttestationChallengeType, // only supported challenge for now
Token: types.CreateNonceEncodedForHeader(),
Status: types.ChallengeStatusPending, // always pending at creation
}
order, err := s.store.CreateOrder(ctx, order, authz, challenge)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "creating order in datastore")
}
return s.createOrderResponse(ctx, enrollment, order, []*types.Authorization{authz})
}
func (s *Service) createOrderResponse(
ctx context.Context,
enrollment *types.Enrollment,
order *types.Order,
authorizations []*types.Authorization,
) (*types.OrderResponse, error) {
baseURL, err := s.getACMEBaseURL(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
}
orderURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "orders", fmt.Sprint(order.ID))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing order URL for account")
}
finalizeURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "orders", fmt.Sprint(order.ID), "finalize")
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing finalize URL for account")
}
var authzURL string
if len(authorizations) == 1 {
authzURL, err = s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "authorizations", fmt.Sprint(authorizations[0].ID))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing authorization URL for account")
}
}
var certURL string
if err := order.IsCertificateReady(); err == nil {
certURL, err = s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "orders", fmt.Sprint(order.ID), "certificate")
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing certificate URL for account")
}
}
return &types.OrderResponse{
ID: order.ID,
Status: order.Status,
Expires: enrollment.NotValidAfter,
Identifiers: order.Identifiers,
Authorizations: []string{authzURL},
Finalize: finalizeURL,
Certificate: certURL,
Location: orderURL,
}, nil
}
func (s *Service) FinalizeOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, orderID uint, csr string) (*types.OrderResponse, error) {
order, authorizations, err := s.store.GetOrderByID(ctx, account.ID, orderID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting order from datastore")
}
if err := order.IsReadyToFinalize(); err != nil {
return nil, err
}
baseURL, err := s.getACMEBaseURL(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
}
for _, authz := range authorizations {
authzURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "authorizations", fmt.Sprint(authz.ID))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing authorization URL for account")
}
if authz.Status != types.AuthorizationStatusValid {
return nil, types.OrderNotReadyError(fmt.Sprintf("Order has correct status but authorization %s has status %s.", authzURL, authz.Status))
}
challenges, err := s.store.GetChallengesByAuthorizationID(ctx, authz.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting challenges for authorization from datastore")
}
hasAValidChallenge := false
for _, chlg := range challenges {
if chlg.Status == types.ChallengeStatusValid {
hasAValidChallenge = true
break
}
}
if !hasAValidChallenge {
return nil, types.OrderNotReadyError(fmt.Sprintf("Order has correct status but no valid challenges for authorization %s.", authzURL))
}
}
// The RFC 7.4 calls out that for the CSR it sends a base64url-encoded DER (so not a full PEM block)
parsedCSR, err := parseDERCSR(csr)
if err != nil {
return nil, types.BadCSRError(fmt.Sprintf("Error parsing DER CSR: %s", err))
}
if parsedCSR.Subject.CommonName != order.Identifiers[0].Value {
return nil, types.BadCSRError("CSR common name does not match identifier value")
}
// We only support ecdsa CSRs for now since that's what the Apple MDM protocol supports, so if it's not an ECDSA CSR we
// return a bad CSR error. We can always add support for more types later if needed. This mirrors the logic we use with
// the JWK
if parsedCSR.PublicKeyAlgorithm != x509.ECDSA {
return nil, types.BadCSRError("Public key is not an Elliptic Curve key as expected")
}
err = parsedCSR.CheckSignature()
if err != nil {
return nil, types.BadCSRError("CSR signature is invalid")
}
// Update the CSR common name and OU to match Fleet-issued SCEP certs
parsedCSR.Subject.CommonName = "Fleet Identity"
parsedCSR.Subject.OrganizationalUnit = []string{"fleet"}
signer, err := s.providers.CSRSigner(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting CSR signer")
}
cert, err := signer.SignCSR(ctx, parsedCSR)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "signing CSR")
}
err = s.store.FinalizeOrder(ctx, orderID, csr, cert.SerialNumber.Int64())
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "finalizing order")
}
order.Status = types.OrderStatusValid
order.Finalized = true
return s.createOrderResponse(ctx, enrollment, order, authorizations)
}
func parseDERCSR(csr string) (*x509.CertificateRequest, error) {
// The CSR is base64 url encoded
base64DecodedCSR, err := base64.RawURLEncoding.DecodeString(csr)
if err != nil {
return nil, types.BadCSRError(fmt.Sprintf("Error decoding base64 CSR: %s", err))
}
parsedCSR, err := x509.ParseCertificateRequest(base64DecodedCSR)
if err != nil {
return nil, fmt.Errorf("error parsing certificate request: %w", err)
}
return parsedCSR, nil
}
func (s *Service) GetOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, orderID uint) (*types.OrderResponse, error) {
// authorization is checked in the endpoint implementation for JWS-protected endpoints
order, authorizations, err := s.store.GetOrderByID(ctx, account.ID, orderID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "get order from datastore")
}
return s.createOrderResponse(ctx, enrollment, order, authorizations)
}
func (s *Service) ListAccountOrders(ctx context.Context, pathIdentifier string, account *types.Account) ([]string, error) {
// authorization is checked in the endpoint implementation for JWS-protected endpoints
orderIDs, err := s.store.ListAccountOrderIDs(ctx, account.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "listing account order IDs from datastore")
}
var orderURLs []string
if len(orderIDs) > 0 {
baseURL, err := s.getACMEBaseURL(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
}
orderURLs = make([]string, len(orderIDs))
for i, orderID := range orderIDs {
orderURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, pathIdentifier, "orders", fmt.Sprint(orderID))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing order URL for account")
}
orderURLs[i] = orderURL
}
}
return orderURLs, nil
}
func (s *Service) GetCertificate(ctx context.Context, accountID, orderID uint) (string, error) {
// authorization is checked in the endpoint implementation for JWS-protected endpoints
order, _, err := s.store.GetOrderByID(ctx, accountID, orderID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "get order from datastore")
}
if err := order.IsCertificateReady(); err != nil {
return "", err
}
certPEM, err := s.store.GetCertificatePEMByOrderID(ctx, accountID, orderID)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "get certificate from datastore")
}
if !strings.HasSuffix(certPEM, "\n") {
certPEM += "\n"
}
// retrieve the root certificate
rootPEMBytes, err := s.providers.GetCACertificatePEM(ctx)
if err != nil {
return "", ctxerr.Wrap(ctx, err, "getting Apple SCEP/ACME root certificate")
}
block, _ := pem.Decode(rootPEMBytes)
if block == nil || block.Type != "CERTIFICATE" {
return "", ctxerr.New(ctx, "failed to parse PEM block from root SCEP/ACME certificate")
}
rootPEM := string(pem.EncodeToMemory(block))
return certPEM + rootPEM, nil
}

View file

@ -0,0 +1,198 @@
package service
import (
"context"
"crypto/ecdsa"
"encoding/json"
"fmt"
"math"
"net/url"
"slices"
"strconv"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
api_http "github.com/fleetdm/fleet/v4/server/mdm/acme/api/http"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm"
"go.step.sm/crypto/jose"
)
var acceptableSignatureAlgorithms = [...]string{
jose.ES256,
jose.ES384,
jose.ES512,
}
func (s *Service) authenticateWithACMEEnrollment(ctx context.Context, identifier string) (*types.Enrollment, error) {
enrollment, err := s.store.GetACMEEnrollment(ctx, identifier)
if err != nil {
return nil, err
}
if !enrollment.IsValid() {
err = types.EnrollmentNotFoundError(fmt.Sprintf("ACME enrollment with path identifier %s not found", identifier))
return nil, ctxerr.Wrap(ctx, err)
}
return enrollment, nil
}
// common authentication logic for both AuthenticateNewAccountMessage and AuthenticateMessageFromAccount, only
// one of createNewAccount or otherRequest must be non-nil.
func (s *Service) commonAuthenticateMessage(ctx context.Context, message *api_http.JWSRequestContainer, createNewAccount *api_http.CreateNewAccountRequest, otherRequest types.AccountAuthenticatedRequest) error {
var err error
// consume the nonce as first validation
nonce := message.JWS.Signatures[0].Protected.Nonce
nonceValid, err := s.nonces.Consume(ctx, nonce)
if !nonceValid || err != nil {
// if there is an error, it is a Redis/network issue, so keep it as a 500
if err == nil {
err = types.BadNonceError("")
}
return ctxerr.Wrapf(ctx, err, "invalid nonce in JWS message for identifier %s", message.Identifier)
}
if createNewAccount != nil {
// must have the JWK
if message.Key == nil {
err = types.UnauthorizedError("missing JWK in JWS message for new account creation")
return ctxerr.Wrap(ctx, err)
}
// For Apple ACME purposes we only support ECDSA hardware-bound keys so validate the key is of the correct type
// and the algorithm is of a proper type for the key (which also ensures it isn't none)
_, ok := message.Key.Key.(*ecdsa.PublicKey)
if !ok {
err = types.BadPublicKeyError("JWK in JWS message for new account creation is not an ECDSA public key")
return ctxerr.Wrap(ctx, err)
}
}
if otherRequest != nil {
// must have the kid
if message.KeyID == nil || *message.KeyID == "" {
err = types.UnauthorizedError("missing kid in JWS message for account-authenticated request")
return ctxerr.Wrap(ctx, err)
}
}
if !slices.Contains(acceptableSignatureAlgorithms[:], message.JWS.Signatures[0].Protected.Algorithm) {
err = types.BadSignatureAlgorithmError(fmt.Sprintf("unsupported signature algorithm %s in JWS message", message.JWS.Signatures[0].Protected.Algorithm))
return ctxerr.Wrap(ctx, err)
}
// "url" field validation: https://datatracker.ietf.org/doc/html/rfc8555/#section-6.4.1
baseURL, err := s.getACMEBaseURL(ctx)
if err != nil {
return ctxerr.New(ctx, "get base ACME URL")
}
expectedURL, err := commonmdm.ResolveURL(baseURL, message.HTTPPath, true)
if err != nil {
return ctxerr.New(ctx, "get expected ACME URL")
}
if message.JWSHeaderURL != expectedURL {
err = types.UnauthorizedError("invalid url in JWS protected header")
return ctxerr.Wrap(ctx, err)
}
// authenticate the enrollment identifier from the path
enrollment, err := s.authenticateWithACMEEnrollment(ctx, message.Identifier)
if err != nil {
return err
}
webKeyToVerify := message.Key
var account *types.Account
if otherRequest != nil {
accountID, err := s.accountIDFromKeyID(ctx, *message.KeyID, message.Identifier)
if err != nil {
return ctxerr.Wrap(ctx, err, "parsing account ID from key ID")
}
account, err = s.store.GetAccountByID(ctx, enrollment.ID, accountID)
if err != nil {
return ctxerr.Wrap(ctx, err, "fetching account by ID")
}
webKeyToVerify = &account.JSONWebKey
}
payload, err := message.JWS.Verify(webKeyToVerify)
if err != nil {
err = types.UnauthorizedError(err.Error()) // I think it's safe to return the error as details here?
return ctxerr.Wrap(ctx, err)
}
if message.PostAsGet && len(payload) != 0 {
err = types.MalformedError("payload must be empty for POST-as-GET requests")
return ctxerr.Wrap(ctx, err)
}
var requestPayload any
requestPayload = createNewAccount
if otherRequest != nil {
requestPayload = otherRequest
}
// From the RFC, for a POST-as-GET request, the payload is an empty string (absent),
// which would fail to unmarshal into an object, so we check it explicitly.
if len(payload) != 0 {
err = json.Unmarshal(payload, requestPayload)
if err != nil {
err = types.MalformedError(fmt.Sprintf("Failed to unmarshal JWS payload: %v", err))
return ctxerr.Wrap(ctx, err)
}
}
if createNewAccount != nil {
createNewAccount.JSONWebKey = message.Key
createNewAccount.Enrollment = enrollment
}
if otherRequest != nil {
otherRequest.SetEnrollmentAndAccount(enrollment, account)
}
return nil
}
func (s *Service) AuthenticateNewAccountMessage(ctx context.Context, message *api_http.JWSRequestContainer, request *api_http.CreateNewAccountRequest) error {
return s.commonAuthenticateMessage(ctx, message, request, nil)
}
func (s *Service) AuthenticateMessageFromAccount(ctx context.Context, message *api_http.JWSRequestContainer, request types.AccountAuthenticatedRequest) error {
return s.commonAuthenticateMessage(ctx, message, nil, request)
}
func (s *Service) accountIDFromKeyID(ctx context.Context, keyID, pathIdentifier string) (uint, error) {
// The key ID is the account URL, which should be in the format /api/mdm/acme/{identifier}/accounts/{accountID}
// We can parse the account ID out of the URL to look up the account in the database
urlParsed, err := url.Parse(keyID)
if err != nil {
err = types.UnauthorizedError("Invalid key ID URL")
return 0, ctxerr.Wrap(ctx, err)
}
expectedURL, err := s.getACMEURL(ctx, pathIdentifier, "accounts")
if err != nil {
// this is not an ACME error, it's a server error
return 0, ctxerr.Wrap(ctx, err, "getting expected account URL")
}
expectedParsed, err := url.Parse(expectedURL)
if err != nil {
// same here, not an error for a client-provided value
return 0, ctxerr.Wrap(ctx, err, "parsing expected account URL")
}
prefix := expectedParsed.Path + "/"
if !strings.HasPrefix(urlParsed.Path, prefix) {
err = types.UnauthorizedError("Invalid key ID URL")
return 0, ctxerr.Wrap(ctx, err)
}
accountIDStr := strings.TrimPrefix(urlParsed.Path, prefix)
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
if err != nil {
err = types.UnauthorizedError("Invalid key ID URL")
return 0, ctxerr.Wrap(ctx, err)
}
if accountID > uint64(math.MaxUint) {
err = types.UnauthorizedError("Invalid key ID URL")
return 0, ctxerr.Wrap(ctx, err)
}
return uint(accountID), nil
}

View file

@ -0,0 +1,63 @@
package service
import (
"context"
"fmt"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
)
// This file does not handle normal authentication, but the ACME concept of authorization as part of the protocol.
func (s *Service) GetAuthorization(ctx context.Context, enrollment *types.Enrollment, account *types.Account, authorizationID uint) (*types.AuthorizationResponse, error) {
if authorizationID == 0 {
return nil, types.MalformedError("invalid authorization ID")
}
authz, err := s.store.GetAuthorizationByID(ctx, account.ID, authorizationID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting authorization by ID")
}
challenges, err := s.store.GetChallengesByAuthorizationID(ctx, authz.ID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting challenges by authorization ID")
}
baseURL, err := s.getACMEBaseURL(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
}
var challengeResponses []types.ChallengeResponse
for _, c := range challenges {
challengeURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "challenges", fmt.Sprint(c.ID))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing challenge URL")
}
challengeResponse := types.ChallengeResponse{
ChallengeType: c.ChallengeType,
Status: c.Status,
Token: c.Token,
URL: challengeURL,
Validated: c.ValidatedAt(),
}
challengeResponses = append(challengeResponses, challengeResponse)
}
authzURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "authorizations", fmt.Sprint(authz.ID))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing authorization URL")
}
return &types.AuthorizationResponse{
Status: authz.Status,
Expires: enrollment.NotValidAfter,
Identifier: authz.Identifier,
Challenges: challengeResponses,
Location: authzURL,
}, nil
}

View file

@ -0,0 +1,205 @@
package service
import (
"context"
"crypto/sha256"
"crypto/subtle"
"crypto/x509"
"encoding/asn1"
"encoding/base64"
"encoding/pem"
"fmt"
"time"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/fxamacker/cbor/v2"
)
const appleEnterpriseAttestationRootCA = `-----BEGIN CERTIFICATE-----
MIICJDCCAamgAwIBAgIUQsDCuyxyfFxeq/bxpm8frF15hzcwCgYIKoZIzj0EAwMw
UTEtMCsGA1UEAwwkQXBwbGUgRW50ZXJwcmlzZSBBdHRlc3RhdGlvbiBSb290IENB
MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yMjAyMTYxOTAx
MjRaFw00NzAyMjAwMDAwMDBaMFExLTArBgNVBAMMJEFwcGxlIEVudGVycHJpc2Ug
QXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UE
BhMCVVMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT6Jigq+Ps9Q4CoT8t8q+UnOe2p
oT9nRaUfGhBTbgvqSGXPjVkbYlIWYO+1zPk2Sz9hQ5ozzmLrPmTBgEWRcHjA2/y7
7GEicps9wn2tj+G89l3INNDKETdxSPPIZpPj8VmjQjBAMA8GA1UdEwEB/wQFMAMB
Af8wHQYDVR0OBBYEFPNqTQGd8muBpV5du+UIbVbi+d66MA4GA1UdDwEB/wQEAwIB
BjAKBggqhkjOPQQDAwNpADBmAjEA1xpWmTLSpr1VH4f8Ypk8f3jMUKYz4QPG8mL5
8m9sX/b2+eXpTv2pH4RZgJjucnbcAjEA4ZSB6S45FlPuS/u4pTnzoz632rA+xW/T
ZwFEh9bhKjJ+5VQ9/Do1os0u3LEkgN/r
-----END CERTIFICATE-----`
var (
OIDAppleSerialNumber = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 8, 9, 1}
OIDAppleNonce = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 8, 11, 1}
)
func (s *Service) ValidateChallenge(ctx context.Context, enrollment *types.Enrollment, account *types.Account, challengeID uint, payload string) (*types.ChallengeResponse, error) {
challenge, err := s.store.GetChallengeByID(ctx, account.ID, challengeID)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting challenge by ID")
}
if challenge.Status != types.ChallengeStatusPending {
return nil, types.InvalidChallengeStatusError(fmt.Sprintf("Challenge with ID %d is not pending and can not be validated", challengeID))
}
var validationErr error
var updatedChallenge *types.Challenge
switch challenge.ChallengeType {
case types.DeviceAttestationChallengeType:
updatedChallenge, validationErr = s.validateDeviceAttestationChallenge(ctx, enrollment, challenge, payload)
default:
return nil, types.InternalServerError(fmt.Sprintf("unsupported challenge type %s", challenge.ChallengeType))
}
if validationErr != nil && updatedChallenge == nil {
return nil, validationErr
}
// We always call UpdateChallenge here since we update it's validity by reference
// this internally updates challenge status, authorization status and order status, which we can
// confidently do with only one authorization and one challenge per order, if we add more we need to re-work this.
if updatedChallenge, err = s.store.UpdateChallenge(ctx, updatedChallenge); err != nil {
return nil, ctxerr.Wrap(ctx, err, "updating challenge status")
}
if validationErr != nil {
return nil, validationErr
}
challengeResponse := &types.ChallengeResponse{
ChallengeType: updatedChallenge.ChallengeType,
Status: updatedChallenge.Status,
Token: updatedChallenge.Token,
Validated: updatedChallenge.ValidatedAt(),
}
baseURL, err := s.getACMEBaseURL(ctx)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
}
challengeURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "challenges", fmt.Sprint(updatedChallenge.ID))
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "constructing challenge URL")
}
challengeResponse.URL = challengeURL
challengeResponse.Location = challengeURL
return challengeResponse, nil
}
func (s *Service) validateDeviceAttestationChallenge(ctx context.Context, enrollment *types.Enrollment, challenge *types.Challenge, payload string) (*types.Challenge, error) {
base64Decoded, err := base64.RawURLEncoding.DecodeString(payload)
if err != nil {
return nil, types.MalformedError(fmt.Sprintf("Failed to base64 decode payload: %v", err))
}
// Verify the CBOR structure
if err := cbor.Wellformed(base64Decoded); err != nil {
return nil, types.BadAttestationStatementError(fmt.Sprintf("Device attestation statement is not correctly CBOR formatted: %s", err.Error()))
}
var attestationObject types.AttestationObject
if err := cbor.Unmarshal(base64Decoded, &attestationObject); err != nil {
return nil, types.BadAttestationStatementError(fmt.Sprintf("Failed to unmarshal CBOR payload into attestation object: %s", err.Error()))
}
switch attestationObject.Format {
case "apple":
var appleStmt types.AppleDeviceAttestationStatement
if err := cbor.Unmarshal(attestationObject.AttestationStatement, &appleStmt); err != nil {
return nil, types.BadAttestationStatementError(fmt.Sprintf("Failed to unmarshal CBOR attestation statement into Apple format: %s", err.Error()))
}
return challenge, s.validateAppleDeviceAttestationStatement(ctx, enrollment, challenge, appleStmt)
default:
return nil, types.BadAttestationStatementError(fmt.Sprintf("Unsupported device attestation format: %s", attestationObject.Format))
}
}
// Challenge status is updated by reference
func (s *Service) validateAppleDeviceAttestationStatement(ctx context.Context, enrollment *types.Enrollment, challenge *types.Challenge, attStmt types.AppleDeviceAttestationStatement) error {
roots := s.TestAppleRootCAs
if roots == nil {
roots = x509.NewCertPool()
rootCABlock, _ := pem.Decode([]byte(appleEnterpriseAttestationRootCA))
if rootCABlock == nil {
return types.BadAttestationStatementError("Failed to parse Apple Enterprise Attestation Root CA certificate")
}
rootCA, err := x509.ParseCertificate(rootCABlock.Bytes)
if err != nil {
return types.BadAttestationStatementError(fmt.Sprintf("Failed to parse Apple Enterprise Attestation Root CA certificate: %s", err.Error()))
}
roots.AddCert(rootCA)
}
if len(attStmt.X5C) < 1 {
return types.BadAttestationStatementError("Apple device attestation statement must contain at least one certificate in x5c field")
}
leaf, err := x509.ParseCertificate(attStmt.X5C[0])
if err != nil {
return types.BadAttestationStatementError(fmt.Sprintf("Failed to parse leaf certificate in Apple device attestation statement: %s", err.Error()))
}
intermediates := x509.NewCertPool()
for _, certBytes := range attStmt.X5C[1:] {
cert, err := x509.ParseCertificate(certBytes)
if err != nil {
return types.BadAttestationStatementError(fmt.Sprintf("Failed to parse intermediate certificate in Apple device attestation statement: %s", err.Error()))
}
intermediates.AddCert(cert)
}
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
CurrentTime: time.Now().Truncate(time.Second),
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}); err != nil {
return types.BadAttestationStatementError(fmt.Sprintf("Failed to verify Apple Root CA is part of certificate chain: %s", err.Error()))
}
// TODO: Should we do any validation on leaf.PublicKey? Apple docs on validation calls out "Retain the public key in the attestation leaf certificate for a later validation."
// So unsure if we should persist it, or what the later validation might be.
appleData := struct {
SerialNumber string
Nonce []byte
}{}
for _, ext := range leaf.Extensions {
if ext.Id.Equal(OIDAppleSerialNumber) {
appleData.SerialNumber = string(ext.Value)
} else if ext.Id.Equal(OIDAppleNonce) {
appleData.Nonce = ext.Value
}
}
sha256Token := sha256.Sum256([]byte(challenge.Token))
if subtle.ConstantTimeCompare(appleData.Nonce, sha256Token[:]) != 1 {
challenge.MarkInvalid()
return types.BadAttestationStatementError("Apple freshness nonce does not match challenge token")
}
if appleData.SerialNumber != enrollment.HostIdentifier {
challenge.MarkInvalid()
return types.BadAttestationStatementError("Serial number in certificate does not match enrollment's host identifier")
}
enrolled, err := s.providers.IsDEPEnrolled(ctx, appleData.SerialNumber)
if err != nil {
return ctxerr.Wrap(ctx, err, "checking DEP enrollment for serial number in attestation certificate")
}
if !enrolled {
challenge.MarkInvalid()
return types.BadAttestationStatementError("No DEP assignments found for serial number in certificate")
}
challenge.MarkValid()
return nil
}

View file

@ -0,0 +1,45 @@
package service
import (
"context"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
)
func (s *Service) NewNonce(ctx context.Context, identifier string) error {
// authentication is via the identifier, that must exist as a valid ACME enrollment
if _, err := s.authenticateWithACMEEnrollment(ctx, identifier); err != nil {
return err
}
// actual nonce generation happens in the rendering of the response
return nil
}
func (s *Service) GetDirectory(ctx context.Context, identifier string) (*types.Directory, error) {
// authentication is via the identifier, that must exist as a valid ACME enrollment
if _, err := s.authenticateWithACMEEnrollment(ctx, identifier); err != nil {
return nil, err
}
baseURL, err := s.getACMEBaseURL(ctx)
if err != nil {
return nil, err
}
suffixes := []string{"new_nonce", "new_account", "new_order"}
urls := make(map[string]string, len(suffixes))
for _, suffix := range suffixes {
u, err := s.getACMEURLWithBaseURL(ctx, baseURL, identifier, suffix)
if err != nil {
return nil, err
}
urls[suffix] = u
}
return &types.Directory{
NewNonce: urls["new_nonce"],
NewAccount: urls["new_account"],
NewOrder: urls["new_order"],
}, nil
}

View file

@ -0,0 +1,135 @@
package service
import (
"context"
"crypto/x509"
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"reflect"
"github.com/fleetdm/fleet/v4/server/mdm/acme/api"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
)
// encodeResponse encodes the response as JSON.
func encodeResponse(ctx context.Context, w http.ResponseWriter, response any) error {
return eu.EncodeCommonResponse(ctx, w, response,
func(w http.ResponseWriter, response any) error {
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(response)
},
acmeDomainErrorEncoder,
)
}
func acmeErrorEncoder(ctx context.Context, err error, w http.ResponseWriter) {
var acmeErr *types.ACMEError
if !errors.As(err, &acmeErr) {
// TODO: If we can get access to a logger, we can log the details here, to help troubleshoot service errors.
// if it's not already an ACME error, it is because it is an internal server
// error (or a dev error, for 4xx we should always return ACMEError).
acmeErr = types.InternalServerError("") // not passing err.Error() as we don't want to leak internal details
}
w.Header().Set("Content-Type", "application/problem+json")
statusCode := acmeErr.StatusCode
if statusCode == 0 {
statusCode = http.StatusInternalServerError
}
w.WriteHeader(statusCode)
// ignoring error as response started being written at that point
_ = json.NewEncoder(w).Encode(acmeErr)
}
func acmeDomainErrorEncoder(ctx context.Context, err error, w http.ResponseWriter, enc *json.Encoder, jsonErr *eu.JsonError) (handled bool) {
acmeErrorEncoder(ctx, err, w)
return true
}
// makeDecoder creates a decoder for the given request type.
func makeDecoder(iface any, requestBodySizeLimit int64) kithttp.DecodeRequestFunc {
return eu.MakeDecoder(iface, func(body io.Reader, req any) error {
return json.NewDecoder(body).Decode(req)
}, parseCustomTags, isBodyDecoder, decodeBody, nil, requestBodySizeLimit)
}
// parseCustomTags handles custom URL tag values for acme requests.
func parseCustomTags(urlTagValue string, r *http.Request, field reflect.Value) (bool, error) {
switch urlTagValue {
case "http_method":
field.Set(reflect.ValueOf(r.Method))
return true, nil
case "http_path":
field.Set(reflect.ValueOf(r.URL.Path))
return true, nil
}
return false, nil
}
func isBodyDecoder(v reflect.Value) bool {
_, ok := v.Interface().(bodyDecoder)
return ok
}
type bodyDecoder interface {
DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error
}
func decodeBody(ctx context.Context, r *http.Request, v reflect.Value, body io.Reader) error {
bd := v.Interface().(bodyDecoder)
var certs []*x509.Certificate
if (r.TLS != nil) && (r.TLS.PeerCertificates != nil) {
certs = r.TLS.PeerCertificates
}
if err := bd.DecodeBody(ctx, body, r.URL.Query(), certs); err != nil {
return err
}
return nil
}
// handlerFunc is the handler function type for ACME service endpoints.
type handlerFunc func(ctx context.Context, request any, svc api.Service) platform_http.Errorer
type endpointer struct {
svc api.Service
}
func (e *endpointer) CallHandlerFunc(f handlerFunc, ctx context.Context,
request any,
svc any,
) (platform_http.Errorer, error) {
return f(ctx, request, svc.(api.Service)), nil
}
func (e *endpointer) Service() any {
return e.svc
}
// Compile-time check to ensure endpointer implements Endpointer.
var _ eu.Endpointer[handlerFunc] = &endpointer{}
func newEndpointerWithNoAuth(svc api.Service, authMiddleware endpoint.Middleware, opts []kithttp.ServerOption, r *mux.Router,
versions ...string,
) *eu.CommonEndpointer[handlerFunc] {
return &eu.CommonEndpointer[handlerFunc]{
EP: &endpointer{
svc: svc,
},
MakeDecoderFn: makeDecoder,
EncodeFn: encodeResponse,
Opts: opts,
AuthMiddleware: authMiddleware,
Router: r,
Versions: versions,
}
}

View file

@ -0,0 +1,16 @@
package service
import (
"context"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
)
func (s *Service) NewACMEEnrollment(ctx context.Context, hostIdentifier string) (string, error) {
// skipauth: No authorization check needed; caller is authenticated via DEP device identity.
if az, ok := authz_ctx.FromContext(ctx); ok {
az.SetChecked()
}
return s.store.NewEnrollment(ctx, hostIdentifier)
}

View file

@ -0,0 +1,260 @@
package service
import (
"context"
"fmt"
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
"github.com/fleetdm/fleet/v4/server/mdm/acme/api"
api_http "github.com/fleetdm/fleet/v4/server/mdm/acme/api/http"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
"github.com/go-kit/kit/endpoint"
kithttp "github.com/go-kit/kit/transport/http"
"github.com/gorilla/mux"
)
// GetRoutes returns a function that registers ACME routes on the router using the provided
// authMiddleware.
func GetRoutes(svc api.Service, authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc {
return func(r *mux.Router, opts []kithttp.ServerOption) {
attachFleetAPIRoutes(r, svc, authMiddleware, opts)
}
}
func attachFleetAPIRoutes(r *mux.Router, svc api.Service, authMiddleware endpoint.Middleware, opts []kithttp.ServerOption) {
opts = append(opts, kithttp.ServerErrorEncoder(acmeErrorEncoder))
ae := newEndpointerWithNoAuth(svc, authMiddleware, opts, r)
// ACME endpoints use path identifier and JWS authn/z, so we use a middleware to mark
// the standard Fleet auth as skipped/done so the endpoints don't return a Forbidden
// error due to no standard auth done.
ae = ae.WithCustomMiddlewareAfterAuth(skipStandardFleetAuth())
// must support HEAD, GET and POST-as-GET for new_nonce as per
// https://datatracker.ietf.org/doc/html/rfc8555/#section-6.3 and
// https://datatracker.ietf.org/doc/html/rfc8555/#section-7.2
ae.GET("/api/mdm/acme/{identifier}/new_nonce", getNewNonceEndpoint, api_http.GetNewNonceRequest{})
ae.HEAD("/api/mdm/acme/{identifier}/new_nonce", getNewNonceEndpoint, api_http.GetNewNonceRequest{})
ae.POST("/api/mdm/acme/{identifier}/new_nonce", getNewNonceEndpoint, api_http.GetNewNonceRequest{})
// must support GET and POST-as-GET for directory as per
// https://datatracker.ietf.org/doc/html/rfc8555/#section-6.3 and
// https://datatracker.ietf.org/doc/html/rfc8555/#section-7.1.1
ae.GET("/api/mdm/acme/{identifier}/directory", getDirectoryEndpoint, api_http.GetDirectoryRequest{})
ae.POST("/api/mdm/acme/{identifier}/directory", getDirectoryEndpoint, api_http.GetDirectoryRequest{})
ae.POST("/api/mdm/acme/{identifier}/new_account", createAccountEndpoint, api_http.JWSRequestContainer{})
ae.POST("/api/mdm/acme/{identifier}/new_order", createOrderEndpoint, api_http.JWSRequestContainer{})
// POST-as-GET for order endpoint, as per RFC.
ae.POST("/api/mdm/acme/{identifier}/orders/{order_id}", getOrderEndpoint, api_http.GetOrderRequest{})
// POST-as-GET for list orders endpoint, as per RFC.
ae.POST("/api/mdm/acme/{identifier}/accounts/{account_id}/orders", listOrdersEndpoint, api_http.ListOrdersRequest{})
// POST-as-GET for download certificate endpoint, as per RFC.
ae.POST("/api/mdm/acme/{identifier}/orders/{order_id}/certificate", getCertificateEndpoint, api_http.GetCertificateRequest{})
ae.POST("/api/mdm/acme/{identifier}/authorizations/{authorization_id}", getAuthorizationEndpoint, api_http.GetAuthorizationRequest{})
ae.POST("/api/mdm/acme/{identifier}/challenges/{challenge_id}", getChallengeEndpoint, api_http.DoChallengeRequest{})
ae.POST("/api/mdm/acme/{identifier}/orders/{order_id}/finalize", finalizeOrderEndpoint, api_http.FinalizeOrderRequestContainer{})
}
func skipStandardFleetAuth() endpoint.Middleware {
return func(next endpoint.Endpoint) endpoint.Endpoint {
return func(ctx context.Context, req any) (any, error) {
if az, ok := authz_ctx.FromContext(ctx); ok {
az.SetChecked()
}
return next(ctx, req)
}
}
}
// getNewNonceEndpoint handles HEAD/GET/POST /api/mdm/acme/{identifier}/new_nonce requests.
func getNewNonceEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.GetNewNonceRequest)
err := svc.NewNonce(ctx, req.Identifier)
if err != nil {
return &api_http.GetNewNonceResponse{Err: err}
}
return &api_http.GetNewNonceResponse{
HTTPMethod: req.HTTPMethod,
Nonces: svc.NoncesStore(),
}
}
// getDirectoryEndpoint handles GET/POST /api/mdm/acme/{identifier}/directory requests.
func getDirectoryEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.GetDirectoryRequest)
dir, err := svc.GetDirectory(ctx, req.Identifier)
if err != nil {
return api_http.GetDirectoryResponse{Err: err}
}
return api_http.GetDirectoryResponse{Directory: dir}
}
// createAccountEndpoint handles POST /api/mdm/acme/{identifier}/new_account requests.
func createAccountEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.JWSRequestContainer)
newAccountRequest := &api_http.CreateNewAccountRequest{}
err := svc.AuthenticateNewAccountMessage(ctx, req, newAccountRequest)
if err != nil {
return &api_http.CreateNewAccountResponse{Err: err, Nonces: svc.NoncesStore()}
}
accountResp, err := svc.CreateAccount(ctx, req.Identifier, newAccountRequest.Enrollment.ID, *newAccountRequest.JSONWebKey, newAccountRequest.OnlyReturnExisting)
if err != nil {
return &api_http.CreateNewAccountResponse{Err: err, Nonces: svc.NoncesStore()}
}
return &api_http.CreateNewAccountResponse{
Nonces: svc.NoncesStore(),
AccountResponse: accountResp,
}
}
// createOrderEndpoint handles POST /api/mdm/acme/{identifier}/new_order requests.
func createOrderEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.JWSRequestContainer)
newOrderRequest := &api_http.CreateNewOrderRequest{}
err := svc.AuthenticateMessageFromAccount(ctx, req, newOrderRequest)
if err != nil {
return &api_http.CreateNewOrderResponse{Err: err, Nonces: svc.NoncesStore()}
}
partialOrder := &types.Order{
Identifiers: newOrderRequest.Identifiers,
NotBefore: newOrderRequest.NotBefore,
NotAfter: newOrderRequest.NotAfter,
}
orderResp, err := svc.CreateOrder(ctx, newOrderRequest.Enrollment, newOrderRequest.Account, partialOrder)
if err != nil {
return &api_http.CreateNewOrderResponse{Err: err, Nonces: svc.NoncesStore()}
}
return &api_http.CreateNewOrderResponse{
Nonces: svc.NoncesStore(),
OrderResponse: orderResp,
}
}
// getOrderEndpoint handles POST-as-GET /api/mdm/acme/{identifier}/orders/{id} requests.
func getOrderEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.GetOrderRequest)
req.PostAsGet = true
orderRequest := &api_http.GetOrderDecodedRequest{OrderID: req.OrderID}
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, orderRequest)
if err != nil {
return &api_http.GetOrderResponse{Err: err, Nonces: svc.NoncesStore()}
}
orderResp, err := svc.GetOrder(ctx, orderRequest.Enrollment, orderRequest.Account, orderRequest.OrderID)
if err != nil {
return &api_http.GetOrderResponse{Err: err, Nonces: svc.NoncesStore()}
}
return &api_http.GetOrderResponse{
Nonces: svc.NoncesStore(),
OrderResponse: orderResp,
}
}
// listOrdersEndpoint handles POST-as-GET /api/mdm/acme/{identifier}/accounts/{id}/orders requests.
func listOrdersEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.ListOrdersRequest)
req.PostAsGet = true
ordersRequest := &api_http.ListOrdersDecodedRequest{AccountID: req.AccountID}
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, ordersRequest)
if err != nil {
return &api_http.ListOrdersResponse{Err: err, Nonces: svc.NoncesStore()}
}
urls, err := svc.ListAccountOrders(ctx, req.Identifier, ordersRequest.Account)
if err != nil {
return &api_http.ListOrdersResponse{Err: err, Nonces: svc.NoncesStore()}
}
return &api_http.ListOrdersResponse{
Nonces: svc.NoncesStore(),
Orders: urls,
}
}
// getCertificateEndpoint handles POST-as-GET /api/mdm/acme/{identifier}/orders/{id}/certificate requests.
func getCertificateEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.GetCertificateRequest)
req.PostAsGet = true
certReq := &api_http.GetCertificateDecodedRequest{OrderID: req.OrderID}
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, certReq)
if err != nil {
return &api_http.GetCertificateResponse{Err: err, Nonces: svc.NoncesStore()}
}
cert, err := svc.GetCertificate(ctx, certReq.Account.ID, certReq.OrderID)
if err != nil {
return &api_http.GetCertificateResponse{Err: err, Nonces: svc.NoncesStore()}
}
return &api_http.GetCertificateResponse{
Certificate: cert,
Nonces: svc.NoncesStore(),
}
}
// getAuthorizationEndpoint handles POST /api/mdm/acme/{identifier}/authz/{authorization} requests.
func getAuthorizationEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.GetAuthorizationRequest)
req.PostAsGet = true
authzReq := &api_http.GetAuthorizationDecodedRequest{AuthorizationID: req.AuthorizationID}
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, authzReq)
if err != nil {
return &api_http.GetAuthorizationResponse{Err: err, Nonces: svc.NoncesStore()}
}
authzResp, err := svc.GetAuthorization(ctx, authzReq.Enrollment, authzReq.Account, authzReq.AuthorizationID)
if err != nil {
return &api_http.GetAuthorizationResponse{Err: err, Nonces: svc.NoncesStore()}
}
return &api_http.GetAuthorizationResponse{
AuthorizationResponse: authzResp,
Nonces: svc.NoncesStore(),
}
}
func getChallengeEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.DoChallengeRequest)
decodedReq := &api_http.DoChallengeDecodedRequest{ChallengeID: req.ChallengeID}
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, decodedReq)
if err != nil {
return &api_http.DoChallengeResponse{Err: err, Nonces: svc.NoncesStore()}
}
if decodedReq.AttestError != "" {
return &api_http.DoChallengeResponse{
Err: types.UnauthorizedError(fmt.Sprintf("Attestation failure: %s", decodedReq.AttestError)),
Nonces: svc.NoncesStore(),
}
}
challengeResp, err := svc.ValidateChallenge(ctx, decodedReq.Enrollment, decodedReq.Account, decodedReq.ChallengeID, decodedReq.AttestationObject)
if err != nil {
return &api_http.DoChallengeResponse{Err: err, Nonces: svc.NoncesStore()}
}
return &api_http.DoChallengeResponse{
ChallengeResponse: challengeResp,
Nonces: svc.NoncesStore(),
}
}
// finalizeOrderEndpoint handles POST /api/mdm/acme/{identifier}/orders/{order_id}/finalize requests.
func finalizeOrderEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
req := request.(*api_http.FinalizeOrderRequestContainer)
finalizeOrderRequest := &api_http.FinalizeOrderRequest{OrderID: req.OrderID}
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, finalizeOrderRequest)
if err != nil {
return &api_http.FinalizeOrderResponse{Err: err, Nonces: svc.NoncesStore()}
}
order, err := svc.FinalizeOrder(ctx, finalizeOrderRequest.Enrollment, finalizeOrderRequest.Account, finalizeOrderRequest.OrderID, finalizeOrderRequest.CertificateSigningRequest)
if err != nil {
return &api_http.FinalizeOrderResponse{Err: err, Nonces: svc.NoncesStore()}
}
return &api_http.FinalizeOrderResponse{OrderResponse: order, Err: err, Nonces: svc.NoncesStore()}
}

View file

@ -0,0 +1,74 @@
// Package service provides the service implementation for the ACME service module.
package service
import (
"context"
"crypto/x509"
"fmt"
"log/slog"
"strings"
"github.com/fleetdm/fleet/v4/server/mdm/acme"
"github.com/fleetdm/fleet/v4/server/mdm/acme/api"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/redis_nonces_store"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm"
)
// Service is the ACME bounded context service implementation.
type Service struct {
store types.Datastore
nonces *redis_nonces_store.RedisNoncesStore
providers acme.DataProviders
logger *slog.Logger
// Field to set for testing, if not set it will use the hardcoded Apple Enterprise Attestation Root CA
TestAppleRootCAs *x509.CertPool
}
type ServiceOption func(*Service)
// NewService creates a new ACME service.
func NewService(
store types.Datastore,
redisPool acme.RedisPool,
providers acme.DataProviders,
logger *slog.Logger,
opts ...ServiceOption,
) *Service {
noncesStore := redis_nonces_store.New(redisPool)
svc := &Service{
store: store,
nonces: noncesStore,
providers: providers,
logger: logger,
}
for _, opt := range opts {
opt(svc)
}
return svc
}
// Ensure Service implements api.Service
var _ api.Service = (*Service)(nil)
func (s *Service) NoncesStore() *redis_nonces_store.RedisNoncesStore {
return s.nonces
}
func (s *Service) getACMEBaseURL(ctx context.Context) (string, error) {
return s.providers.ServerURL(ctx)
}
func (s *Service) getACMEURL(ctx context.Context, pathIdentifier string, suffixes ...string) (string, error) {
baseURL, err := s.getACMEBaseURL(ctx)
if err != nil {
return "", err
}
return s.getACMEURLWithBaseURL(ctx, baseURL, pathIdentifier, suffixes...)
}
func (s *Service) getACMEURLWithBaseURL(_ context.Context, baseURL, pathIdentifier string, suffixes ...string) (string, error) {
return commonmdm.ResolveURL(baseURL, fmt.Sprintf("/api/mdm/acme/%s/%s", pathIdentifier, strings.Join(suffixes, "/")), true)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,50 @@
package tests
import (
"context"
"errors"
"github.com/fleetdm/fleet/v4/server/mdm/acme"
)
// Mock implementations for dependencies outside the bounded context
// mockDataProviders implements acme.DataProviders for testing.
type mockDataProviders struct {
serverURL string
assets map[string][]byte // asset name → PEM bytes
signer acme.CSRSigner
}
func newMockDataProviders(serverURL string, signer acme.CSRSigner, caCertPEM []byte) *mockDataProviders {
return &mockDataProviders{
serverURL: serverURL,
signer: signer,
assets: map[string][]byte{"ca_cert": caCertPEM},
}
}
func (m *mockDataProviders) ServerURL(_ context.Context) (string, error) {
return m.serverURL, nil
}
func (m *mockDataProviders) GetCACertificatePEM(_ context.Context) ([]byte, error) {
if pem, ok := m.assets["ca_cert"]; ok {
return pem, nil
}
return nil, errors.New("ca_cert not found")
}
func (m *mockDataProviders) CSRSigner(_ context.Context) (acme.CSRSigner, error) {
return m.signer, nil
}
// Returns true for "valid-serial", error for "error-serial", false otherwise
func (m *mockDataProviders) IsDEPEnrolled(_ context.Context, serial string) (bool, error) {
if serial == "valid-serial" {
return true, nil
} else if serial == "error-serial" {
return false, errors.New("Mocked error for IsDEPEnrolled")
}
return false, nil
}

View file

@ -0,0 +1,460 @@
package tests
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
"github.com/fleetdm/fleet/v4/server/mdm/acme"
api_http "github.com/fleetdm/fleet/v4/server/mdm/acme/api/http"
"github.com/fleetdm/fleet/v4/server/mdm/acme/bootstrap"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/mysql"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/service"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/fleetdm/fleet/v4/server/mdm/acme/testhelpers"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/go-kit/kit/endpoint"
"github.com/gorilla/mux"
"github.com/stretchr/testify/require"
"go.step.sm/crypto/jose"
)
// integrationTestSuite holds all dependencies for integration tests.
type integrationTestSuite struct {
*testutils.TestDB
ds *mysql.Datastore
server *httptest.Server
attestCA *x509.Certificate
attestCAKey *ecdsa.PrivateKey
}
// setupIntegrationTest creates a new test suite with a real database and HTTP server.
func setupIntegrationTest(t *testing.T) *integrationTestSuite {
t.Helper()
tdb := testutils.SetupTestDB(t, "acme_integration")
pool := redistest.SetupRedis(t, "acme_integration", false, false, false)
ds := mysql.NewDatastore(tdb.Conns(), tdb.Logger)
cert, key, err := testhelpers.GenerateTestAttestationCA()
require.NoError(t, err)
rootPool := x509.NewCertPool()
rootPool.AddCert(cert)
// Create mocks
providers := newMockDataProviders(
"https://example.com", // will update with actual test server URL after it is started
acme.CSRSignerFunc(func(ctx context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error) {
res, err := tdb.DB.DB.Exec(`INSERT INTO identity_serials () VALUES ()`) // insert a row to get an auto-incremented ID for the cert serial number
require.NoError(t, err)
serialID, err := res.LastInsertId()
require.NoError(t, err)
_, err = tdb.DB.DB.Exec(`INSERT INTO identity_certificates (serial, not_valid_before, not_valid_after, certificate_pem) VALUES (?, NOW(), NOW(), ?)`, serialID, fmt.Appendf(nil, "-----BEGIN CERTIFICATE-----\nmock-cert-%d\n-----END CERTIFICATE-----", serialID))
require.NoError(t, err)
return &x509.Certificate{
SerialNumber: big.NewInt(serialID),
Raw: []byte("mock-cert"),
}, nil
}),
[]byte("-----BEGIN CERTIFICATE-----\nroot\n-----END CERTIFICATE-----"),
)
opts := bootstrap.WithTestAppleRootCAs(rootPool)
// Create service
svc := service.NewService(ds, pool, providers, tdb.Logger, opts)
// Create router with routes
router := mux.NewRouter()
authMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint { return next } // no-op auth middleware for testing
routesFn := service.GetRoutes(svc, authMiddleware)
routesFn(router, nil)
// Create test server
server := httptest.NewServer(router)
t.Cleanup(server.Close)
providers.serverURL = server.URL
return &integrationTestSuite{
TestDB: tdb,
ds: ds,
server: server,
attestCA: cert,
attestCAKey: key,
}
}
// truncateTables clears all test data between tests.
func (s *integrationTestSuite) truncateTables(t *testing.T) {
t.Helper()
s.TruncateTables(t)
}
func drainAndCloseBody(resp *http.Response) {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}
// newNonce makes an HTTP request to new nonce endpoint and returns the parsed response and the raw response.
func (s *integrationTestSuite) newNonce(t *testing.T, httpMethod, pathIdentifier string) (*api_http.GetNewNonceResponse, *http.Response) {
t.Helper()
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/new_nonce", pathIdentifier) //nolint:gosec // test server URL is safe
req, err := http.NewRequest(httpMethod, url, nil)
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer drainAndCloseBody(resp)
result := &api_http.GetNewNonceResponse{
HTTPMethod: resp.Request.Method,
}
return result, resp
}
// doACMERequest is a generic helper that makes an HTTP request, decodes the
// response into T on success, or into an ACMEError on failure (status >= 300).
func doACMERequest[T any](t *testing.T, method, url string, body []byte) (*T, *types.ACMEError, *http.Response) {
t.Helper()
var bodyReader io.Reader
if body != nil {
bodyReader = bytes.NewReader(body)
}
req, err := http.NewRequest(method, url, bodyReader) //nolint:gosec // test server URL is safe
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer drainAndCloseBody(resp)
if resp.StatusCode >= 300 {
var acmeErr types.ACMEError
if err := json.NewDecoder(resp.Body).Decode(&acmeErr); err == nil && acmeErr.Type != "" {
return nil, &acmeErr, resp
}
return nil, nil, resp
}
var result T
err = json.NewDecoder(resp.Body).Decode(&result)
require.NoError(t, err)
return &result, nil, resp
}
// getDirectory makes an HTTP request to get directory endpoint and returns the parsed response and the raw response.
func (s *integrationTestSuite) getDirectory(t *testing.T, httpMethod, pathIdentifier string) (*api_http.GetDirectoryResponse, *http.Response) {
t.Helper()
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/directory", pathIdentifier) //nolint:gosec // test server URL is safe
result, _, resp := doACMERequest[api_http.GetDirectoryResponse](t, httpMethod, url, nil)
return result, resp
}
// staticNonce implements jose.NonceSource with a fixed nonce value.
type staticNonce struct {
nonce string
}
func (s staticNonce) Nonce() (string, error) {
return s.nonce, nil
}
// getNonce obtains a fresh nonce from the new_nonce endpoint for the given enrollment.
func (s *integrationTestSuite) getNonce(t *testing.T, pathIdentifier string) string {
t.Helper()
_, resp := s.newNonce(t, http.MethodGet, pathIdentifier)
require.Equal(t, http.StatusNoContent, resp.StatusCode)
nonce := resp.Header.Get("Replay-Nonce")
require.NotEmpty(t, nonce)
return nonce
}
// buildJWS constructs a JWS in flattened JSON serialization. When accountURL is
// empty, the JWK is embedded in the header (for new-account requests). When
// accountURL is set, it is used as the KeyID instead (for account-authenticated
// requests like new-order).
func buildJWS(t *testing.T, privateKey *ecdsa.PrivateKey, nonce, accountURL, endpointURL string, payload any) []byte {
t.Helper()
opts := &jose.SignerOptions{
NonceSource: staticNonce{nonce: nonce},
ExtraHeaders: map[jose.HeaderKey]any{
"url": endpointURL,
},
}
if accountURL == "" {
opts.EmbedJWK = true
} else {
opts.ExtraHeaders["kid"] = accountURL
}
signer, err := jose.NewSigner(
jose.SigningKey{Algorithm: jose.ES256, Key: privateKey},
opts,
)
require.NoError(t, err)
var payloadBytes []byte
if payload != nil {
payloadBytes, err = json.Marshal(payload)
require.NoError(t, err)
} else {
// as per the RFC:
// > [when doing a POST-as-GET] the "payload" field of the
// > JWS object MUST be present and set to the empty string
// > [...] a zero-length (and thus non-JSON) payload
payloadBytes = []byte("")
}
jws, err := signer.Sign(payloadBytes)
require.NoError(t, err)
return []byte(jws.FullSerialize())
}
// createAccount POSTs a JWS body to the new_account endpoint and returns the
// account response or acme error and the raw response.
func (s *integrationTestSuite) createAccount(t *testing.T, pathIdentifier string, jwsBody []byte) (*types.AccountResponse, *types.ACMEError, *http.Response) {
t.Helper()
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/new_account", pathIdentifier) //nolint:gosec // test server URL is safe
return doACMERequest[types.AccountResponse](t, http.MethodPost, url, jwsBody)
}
// newAccountURL returns the full URL for the new_account endpoint.
func (s *integrationTestSuite) newAccountURL(pathIdentifier string) string {
return fmt.Sprintf("%s/api/mdm/acme/%s/new_account", s.server.URL, pathIdentifier)
}
// newOrderURL returns the full URL for the new_order endpoint.
func (s *integrationTestSuite) newOrderURL(pathIdentifier string) string {
return fmt.Sprintf("%s/api/mdm/acme/%s/new_order", s.server.URL, pathIdentifier)
}
// createAccountForOrder is a convenience helper that creates an account for an enrollment,
// returning the private key, account URL, and a fresh nonce for subsequent requests.
func (s *integrationTestSuite) createAccountForOrder(t *testing.T, enrollment *types.Enrollment) (*ecdsa.PrivateKey, string, string) {
t.Helper()
privateKey, err := testhelpers.GenerateTestKey()
require.NoError(t, err)
nonce := s.getNonce(t, enrollment.PathIdentifier)
jwsBody := buildJWS(t, privateKey, nonce, "", s.newAccountURL(enrollment.PathIdentifier), nil)
_, _, resp := s.createAccount(t, enrollment.PathIdentifier, jwsBody)
require.Equal(t, http.StatusCreated, resp.StatusCode)
accountURL := resp.Header.Get("Location")
require.NotEmpty(t, accountURL)
nextNonce := resp.Header.Get("Replay-Nonce")
require.NotEmpty(t, nextNonce)
return privateKey, accountURL, nextNonce
}
// createOrderForGet is a convenience helper that creates an account and order for an enrollment,
// returning the private key, account URL, order response, and a fresh nonce for subsequent requests.
func (s *integrationTestSuite) createOrderForGet(t *testing.T, enroll *types.Enrollment) (*ecdsa.PrivateKey, string, *types.OrderResponse, string) {
t.Helper()
privateKey, accountURL, nonce := s.createAccountForOrder(t, enroll)
payload := map[string]any{
"identifiers": []map[string]string{
{"type": "permanent-identifier", "value": enroll.HostIdentifier},
},
}
jwsBody := buildJWS(t, privateKey, nonce, accountURL, s.newOrderURL(enroll.PathIdentifier), payload)
orderResp, _, resp := s.createOrder(t, enroll.PathIdentifier, jwsBody)
require.Equal(t, http.StatusCreated, resp.StatusCode)
nextNonce := resp.Header.Get("Replay-Nonce")
require.NotEmpty(t, nextNonce)
return privateKey, accountURL, orderResp, nextNonce
}
// createOrderForChallenge is a convenience helper that creates account+order+fetches
// authorization, returning: privateKey, accountURL, challengeURL, challengeToken, nonce.
func (s *integrationTestSuite) createOrderForChallenge(t *testing.T, enroll *types.Enrollment) (privateKey *ecdsa.PrivateKey, accountURL, challengeURL, challengeToken, nonce string) {
t.Helper()
privateKey, accountURL, orderResp, nonce := s.createOrderForGet(t, enroll)
require.Len(t, orderResp.Authorizations, 1)
authURL := orderResp.Authorizations[0]
authResp, _, resp := s.getAuthorization(t, authURL, buildJWS(t, privateKey, nonce, accountURL, authURL, nil))
require.Equal(t, http.StatusOK, resp.StatusCode)
require.Len(t, authResp.Challenges, 1)
challenge := authResp.Challenges[0]
nonce = resp.Header.Get("Replay-Nonce")
require.NotEmpty(t, nonce)
return privateKey, accountURL, challenge.URL, challenge.Token, nonce
}
// getOrderURL returns the full URL for the get order endpoint.
func (s *integrationTestSuite) getOrderURL(pathIdentifier string, orderID uint) string {
return fmt.Sprintf("%s/api/mdm/acme/%s/orders/%d", s.server.URL, pathIdentifier, orderID)
}
// getOrder POSTs a JWS body to the order endpoint and returns the
// order response or acme error and the raw response.
func (s *integrationTestSuite) getOrder(t *testing.T, pathIdentifier string, orderID uint, jwsBody []byte) (*types.OrderResponse, *types.ACMEError, *http.Response) {
t.Helper()
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/orders/%d", pathIdentifier, orderID) //nolint:gosec // test server URL is safe
return doACMERequest[types.OrderResponse](t, http.MethodPost, url, jwsBody)
}
// createOrder POSTs a JWS body to the new_order endpoint and returns the
// order response or acme error and the raw response.
func (s *integrationTestSuite) createOrder(t *testing.T, pathIdentifier string, jwsBody []byte) (*types.OrderResponse, *types.ACMEError, *http.Response) {
t.Helper()
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/new_order", pathIdentifier) //nolint:gosec // test server URL is safe
return doACMERequest[types.OrderResponse](t, http.MethodPost, url, jwsBody)
}
// listOrdersURL returns the full URL for the list orders endpoint.
func (s *integrationTestSuite) listOrdersURL(pathIdentifier string, accountID uint) string {
return fmt.Sprintf("%s/api/mdm/acme/%s/accounts/%d/orders", s.server.URL, pathIdentifier, accountID)
}
// listOrders POSTs a JWS body to the list orders endpoint and returns the
// list orders response or acme error and the raw response.
func (s *integrationTestSuite) listOrders(t *testing.T, pathIdentifier string, accountID uint, jwsBody []byte) (*api_http.ListOrdersResponse, *types.ACMEError, *http.Response) {
t.Helper()
url := s.listOrdersURL(pathIdentifier, accountID)
return doACMERequest[api_http.ListOrdersResponse](t, http.MethodPost, url, jwsBody)
}
// getCertificateURL returns the full URL for the get certificate endpoint.
func (s *integrationTestSuite) getCertificateURL(pathIdentifier string, orderID uint) string {
return fmt.Sprintf("%s/api/mdm/acme/%s/orders/%d/certificate", s.server.URL, pathIdentifier, orderID)
}
// getCertificate POSTs a JWS body to the certificate endpoint and returns the
// PEM certificate chain (on success) or an ACME error (on failure) and the raw response.
// Unlike other helpers, the success response is raw PEM, not JSON.
func (s *integrationTestSuite) getCertificate(t *testing.T, pathIdentifier string, orderID uint, jwsBody []byte) (string, *types.ACMEError, *http.Response) {
t.Helper()
url := s.getCertificateURL(pathIdentifier, orderID)
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(jwsBody)) //nolint:gosec // test server URL is safe
require.NoError(t, err)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
if resp.StatusCode >= 300 {
var acmeErr types.ACMEError
if err := json.Unmarshal(body, &acmeErr); err == nil && acmeErr.Type != "" {
return "", &acmeErr, resp
}
return "", nil, resp
}
return string(body), nil, resp
}
// finalizeOrderWithCert forces an order to finalized+valid state and inserts a
// linked certificate in the database. This is useful for setting up the state
// needed by the get certificate endpoint and any other test that needs a
// finalized order with a valid certificate.
func (s *integrationTestSuite) finalizeOrderWithCert(t *testing.T, orderID uint, certSerial uint64, certPEM string) {
t.Helper()
ctx := t.Context()
_, err := s.DB.ExecContext(ctx,
`UPDATE acme_orders SET finalized = 1, status = 'valid' WHERE id = ?`, orderID)
require.NoError(t, err)
_, err = s.DB.ExecContext(ctx,
`INSERT INTO identity_serials (serial) VALUES (?)`, certSerial)
require.NoError(t, err)
_, err = s.DB.ExecContext(ctx, `
INSERT INTO identity_certificates (serial, not_valid_before, not_valid_after, certificate_pem, revoked)
VALUES (?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), ?, ?)
`, certSerial, certPEM, false)
require.NoError(t, err)
_, err = s.DB.ExecContext(ctx,
`UPDATE acme_orders SET issued_certificate_serial = ? WHERE id = ?`, certSerial, orderID)
require.NoError(t, err)
}
// parseAccountID extracts the numeric account ID from an account URL like
// ".../accounts/123" or ".../accounts/123/orders".
func parseAccountID(t *testing.T, accountURL string) uint {
t.Helper()
// strip trailing "/orders" if present
u := strings.TrimSuffix(accountURL, "/orders")
parts := strings.Split(u, "/")
idStr := parts[len(parts)-1]
id, err := strconv.ParseUint(idStr, 10, 64)
require.NoError(t, err)
return uint(id)
}
// finalizeOrderURL returns the full URL for the finalize endpoint of a given order.
func (s *integrationTestSuite) finalizeOrderURL(pathIdentifier string, orderID uint) string {
return fmt.Sprintf("%s/api/mdm/acme/%s/orders/%d/finalize", s.server.URL, pathIdentifier, orderID)
}
// finalizeOrder POSTs a JWS body to the finalize endpoint and returns the
// order response or acme error and the raw response.
func (s *integrationTestSuite) finalizeOrder(t *testing.T, finalizeURL string, jwsBody []byte) (*types.OrderResponse, *types.ACMEError, *http.Response) {
t.Helper()
return doACMERequest[types.OrderResponse](t, http.MethodPost, finalizeURL, jwsBody)
}
// createOrderForFinalize is a convenience helper that creates an enrollment, account, and order,
// returning everything needed to test the finalize endpoint.
func (s *integrationTestSuite) createOrderForFinalize(t *testing.T) (enroll *types.Enrollment, privateKey *ecdsa.PrivateKey, accountURL string, orderResp *types.OrderResponse, nonce string) {
t.Helper()
enroll = &types.Enrollment{NotValidAfter: ptr.T(time.Now().Add(24 * time.Hour))}
s.InsertACMEEnrollment(t, enroll)
privateKey, accountURL, nonce = s.createAccountForOrder(t, enroll)
payload := map[string]any{
"identifiers": []map[string]string{
{"type": "permanent-identifier", "value": enroll.HostIdentifier},
},
}
jwsBody := buildJWS(t, privateKey, nonce, accountURL, s.newOrderURL(enroll.PathIdentifier), payload)
orderResp, acmeErr, resp := s.createOrder(t, enroll.PathIdentifier, jwsBody)
require.Nil(t, acmeErr)
require.NotNil(t, orderResp)
nonce = resp.Header.Get("Replay-Nonce")
return enroll, privateKey, accountURL, orderResp, nonce
}
// makeOrderReady transitions the order's authorization and challenge to valid and the order to ready via direct DB updates.
func (s *integrationTestSuite) makeOrderReady(t *testing.T, orderID uint) {
t.Helper()
ctx := t.Context()
_, err := s.DB.ExecContext(ctx, `UPDATE acme_challenges SET status = 'valid' WHERE acme_authorization_id IN (SELECT id FROM acme_authorizations WHERE acme_order_id = ?)`, orderID)
require.NoError(t, err)
_, err = s.DB.ExecContext(ctx, `UPDATE acme_authorizations SET status = 'valid' WHERE acme_order_id = ?`, orderID)
require.NoError(t, err)
_, err = s.DB.ExecContext(ctx, `UPDATE acme_orders SET status = 'ready' WHERE id = ?`, orderID)
require.NoError(t, err)
}
func (s *integrationTestSuite) getAuthorization(t *testing.T, authUrl string, jwsBody []byte) (*api_http.GetAuthorizationResponse, *types.ACMEError, *http.Response) {
t.Helper()
return doACMERequest[api_http.GetAuthorizationResponse](t, http.MethodPost, authUrl, jwsBody)
}
func (s *integrationTestSuite) doChallenge(t *testing.T, challengeURL string, jwsBody []byte) (*api_http.DoChallengeResponse, *types.ACMEError, *http.Response) {
t.Helper()
return doACMERequest[api_http.DoChallengeResponse](t, http.MethodPost, challengeURL, jwsBody)
}

View file

@ -0,0 +1,76 @@
// Package testutils provides shared test utilities for the ACME service module.
package testutils
import (
"log/slog"
"testing"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
mysql_testing_utils "github.com/fleetdm/fleet/v4/server/platform/mysql/testing_utils"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
)
// TestDB holds the database connection for tests.
type TestDB struct {
DB *sqlx.DB
Logger *slog.Logger
}
// SetupTestDB creates a test database with the Fleet schema loaded.
func SetupTestDB(t *testing.T, testNamePrefix string) *TestDB {
t.Helper()
testName, opts := mysql_testing_utils.ProcessOptions(t, &mysql_testing_utils.DatastoreTestOptions{
UniqueTestName: testNamePrefix + "_" + t.Name(),
})
mysql_testing_utils.LoadDefaultSchema(t, testName, opts)
config := mysql_testing_utils.MysqlTestConfig(testName)
db, err := common_mysql.NewDB(config, &common_mysql.DBOptions{}, "")
require.NoError(t, err)
t.Cleanup(func() { db.Close() })
return &TestDB{
DB: db,
Logger: slog.New(slog.DiscardHandler),
}
}
// Conns returns DBConnections for creating a datastore.
func (tdb *TestDB) Conns() *common_mysql.DBConnections {
return &common_mysql.DBConnections{Primary: tdb.DB, Replica: tdb.DB}
}
// TruncateTables clears the tables used by acme bounded context.
func (tdb *TestDB) TruncateTables(t *testing.T) {
t.Helper()
mysql_testing_utils.TruncateTables(t, tdb.DB, tdb.Logger, nil, "acme_enrollments", "acme_accounts", "acme_orders", "acme_authorizations", "acme_challenges", "identity_certificates", "identity_serials")
}
// InsertACMEEnrollment creates an enrollment in the database and updates the enrollment struct
// with the generated identifiers (if they were empty) and unique id.
func (tdb *TestDB) InsertACMEEnrollment(t *testing.T, enrollment *types.Enrollment) {
t.Helper()
ctx := t.Context()
if enrollment.PathIdentifier == "" {
enrollment.PathIdentifier = uuid.NewString()
}
if enrollment.HostIdentifier == "" {
enrollment.HostIdentifier = uuid.NewString()
}
result, err := tdb.DB.ExecContext(ctx, `
INSERT INTO acme_enrollments (path_identifier, host_identifier, not_valid_after, revoked)
VALUES (?, ?, ?, ?)
`, enrollment.PathIdentifier, enrollment.HostIdentifier, enrollment.NotValidAfter, enrollment.Revoked)
require.NoError(t, err)
id, err := result.LastInsertId()
require.NoError(t, err)
enrollment.ID = uint(id) //nolint:gosec // dismiss G115
}

View file

@ -0,0 +1,268 @@
package types
import (
"context"
"fmt"
"time"
"github.com/fxamacker/cbor/v2"
"go.step.sm/crypto/jose"
)
const (
OrderStatusPending = "pending"
OrderStatusReady = "ready"
OrderStatusValid = "valid"
OrderStatusInvalid = "invalid"
AuthorizationStatusPending = "pending"
AuthorizationStatusValid = "valid"
AuthorizationStatusInvalid = "invalid"
ChallengeStatusPending = "pending"
ChallengeStatusValid = "valid"
ChallengeStatusInvalid = "invalid"
)
type Directory struct {
NewNonce string `json:"newNonce"`
NewAccount string `json:"newAccount"`
NewOrder string `json:"newOrder"`
NewAuthz string `json:"newAuthz,omitempty"`
RevokeCert string `json:"revokeCert,omitempty"`
KeyChange string `json:"keyChange,omitempty"`
Meta Meta `json:"meta"`
}
type Meta struct {
TermsOfService string `json:"termsOfService,omitempty"`
Website string `json:"website,omitempty"`
CaaIdentities []string `json:"caaIdentities,omitempty"`
ExternalAccountRequired bool `json:"externalAccountRequired,omitempty"`
}
type Enrollment struct {
ID uint `db:"id"`
PathIdentifier string `db:"path_identifier"`
HostIdentifier string `db:"host_identifier"`
NotValidAfter *time.Time `db:"not_valid_after"`
Revoked bool `db:"revoked"`
}
// IsValid returns true if the enrollment is still valid
// (not revoked and not expired).
func (a *Enrollment) IsValid() bool {
if a.NotValidAfter != nil && !a.NotValidAfter.IsZero() && time.Now().After(*a.NotValidAfter) {
return false
}
return !a.Revoked
}
type Account struct {
ID uint `db:"id"`
ACMEEnrollmentID uint `db:"acme_enrollment_id"`
JSONWebKey jose.JSONWebKey `db:"-"`
JSONWebKeyThumbprint string `db:"json_web_key_thumbprint"`
Revoked bool `db:"revoked"`
}
type AccountResponse struct {
CreatedAccount *Account `json:"-"`
DidCreate bool `json:"-"`
Status string `json:"status"`
Contact []string `json:"contact,omitempty"`
Orders string `json:"orders"`
Location string `json:"-"`
}
type Order struct {
ID uint `db:"id"`
ACMEAccountID uint `db:"acme_account_id"`
Finalized bool `db:"finalized"`
CertificateSigningRequest string `db:"certificate_signing_request"`
// Identifiers is manually serialized to JSON when inserted, and should do the same
// when read (or we could implement sql.Scanner).
Identifiers []Identifier `db:"-"`
Status string `db:"status"`
IssuedCertificateSerial *uint64 `db:"issued_certificate_serial"`
// NotBefore and NotAfter must not be set, we capture them so we can validate
// that they were indeed not provided.
NotBefore *time.Time `db:"-"`
NotAfter *time.Time `db:"-"`
}
// IsReadyToFinalize returns an error if the order is not in a state where it can be finalized.
func (o Order) IsReadyToFinalize() error {
if o.Status != OrderStatusReady || o.Finalized {
extra := ""
if o.Finalized {
extra = " and order has already been finalized"
}
return OrderNotReadyError(fmt.Sprintf("Order is in status %s%s.", o.Status, extra))
}
return nil
}
// IsCertificateReady returns an error if the order is not in a state where the certificate can be retrieved.
func (o Order) IsCertificateReady() error {
if !o.Finalized || o.Status != OrderStatusValid {
if o.Status == OrderStatusInvalid {
return OrderDoesNotExistError("Order is in invalid state, cannot get certificate")
}
return OrderNotFinalizedError("Order is not finalized/in valid state, cannot get certificate")
}
return nil
}
// ValidateOrderCreation validates that the order creation request is valid given the enrollment. It returns an error if the request is not valid.
func (o Order) ValidateOrderCreation(enrollment *Enrollment) error {
// The "identifiers" passed as part of the newOrder request must be an array with a
// single member of type "permanent-identifier" matching the serial specified in the
// acme_enrollment that this enrollment was created for.
if len(o.Identifiers) != 1 || o.Identifiers[0].Type != IdentifierTypePermanentIdentifier {
return UnsupportedIdentifierError("A single identifier of type permanent-identifier must be provided in the order request")
}
if o.Identifiers[0].Value != enrollment.HostIdentifier {
return RejectedIdentifierError("The identifier value does not match the host identifier for this enrollment")
}
// notBefore and notAfter, which are optional, must not be set because fleet is going
// to control these and the Apple payload doesn't allow specification of them.
if o.NotBefore != nil || o.NotAfter != nil {
return MalformedError("notBefore and notAfter must not be set in the order request")
}
return nil
}
type OrderResponse struct {
ID uint `json:"id"`
Status string `json:"status"`
Expires *time.Time `json:"expires,omitempty"`
Identifiers []Identifier `json:"identifiers"`
Authorizations []string `json:"authorizations"`
Finalize string `json:"finalize"`
Certificate string `json:"certificate,omitempty"`
// Location is set in the header, pointing to the created order's URL.
Location string `json:"-"`
}
type Authorization struct {
ID uint `db:"id"`
ACMEOrderID uint `db:"acme_order_id"`
Identifier Identifier `db:"-"`
Status string `db:"status"`
}
type AuthorizationResponse struct {
Status string `json:"status"`
Expires *time.Time `json:"expires,omitempty"`
Identifier Identifier `json:"identifier"`
Challenges []ChallengeResponse `json:"challenges"`
// Location is set in the header, pointing to the requested authorization's URL.
Location string `json:"-"`
}
const (
DeviceAttestationChallengeType string = "device-attest-01"
)
type Challenge struct {
ID uint `db:"id"`
ACMEAuthorizationID uint `db:"acme_authorization_id"`
ChallengeType string `db:"challenge_type"`
Token string `db:"token"`
Status string `db:"status"`
// UpdatedAt is used as validated timestamp if the challenge is valid
UpdatedAt time.Time `db:"updated_at"`
}
// ValidatedAt returns the time that the challenge was validated if it is valid, or nil if it is not valid.
func (c Challenge) ValidatedAt() *time.Time {
if c.Status == ChallengeStatusValid {
return &c.UpdatedAt
}
return nil
}
func (c *Challenge) MarkValid() {
c.Status = ChallengeStatusValid
}
func (c *Challenge) MarkInvalid() {
c.Status = ChallengeStatusInvalid
}
type ChallengeResponse struct {
ChallengeType string `json:"type"`
Status string `json:"status"`
Token string `json:"token"`
URL string `json:"url"`
// Validated is only set in the response when the challenge is valid and has been validated.
Validated *time.Time `json:"validated,omitempty"`
// Location is set in the header, pointing to the requested challenge's URL.
Location string `json:"-"`
}
// https://www.w3.org/TR/webauthn-2/#sctn-attestation, but we don't use authData as per the ACME RFC.
type AttestationObject struct {
Format string `cbor:"fmt"`
AttestationStatement cbor.RawMessage `cbor:"attStmt"`
}
// https://www.w3.org/TR/webauthn-2/#sctn-apple-anonymous-attestation
type AppleDeviceAttestationStatement struct {
X5C [][]byte `cbor:"x5c"`
}
const (
IdentifierTypePermanentIdentifier = "permanent-identifier"
)
type AccountAuthenticatedRequest interface {
SetEnrollmentAndAccount(enrollment *Enrollment, account *Account)
}
// The base struct for allowing arbitrary types to implement the interface above. It is important that these
// members not be serialized to/from JSON as they are meant to be set by the service after authentication and
// not by the client.
type AccountAuthenticatedRequestBase struct {
Enrollment *Enrollment `json:"-"`
Account *Account `json:"-"`
}
func (r *AccountAuthenticatedRequestBase) SetEnrollmentAndAccount(enrollment *Enrollment, account *Account) {
r.Enrollment = enrollment
r.Account = account
}
// Represents acme identifiers (not to be confused with enrollment identifiers)
// which, in our usecase, represent identifiers(e.g. serials) that hosts control.
type Identifier struct {
Type string `json:"type"`
Value string `json:"value"`
}
// Datastore is the datastore interface for the ACME service module.
type Datastore interface {
NewEnrollment(ctx context.Context, hostIdentifier string) (string, error)
GetACMEEnrollment(ctx context.Context, pathIdentifier string) (*Enrollment, error)
GetAccountByID(ctx context.Context, enrollmentID uint, accountID uint) (*Account, error)
CreateAccount(ctx context.Context, account *Account, onlyReturnExisting bool) (*Account, bool, error)
CreateOrder(ctx context.Context, order *Order, authorization *Authorization, challenge *Challenge) (*Order, error)
GetOrderByID(ctx context.Context, accountID, orderID uint) (*Order, []*Authorization, error)
ListAccountOrderIDs(ctx context.Context, accountID uint) ([]uint, error)
GetAuthorizationByID(ctx context.Context, accountID uint, authorizationID uint) (*Authorization, error)
FinalizeOrder(ctx context.Context, orderID uint, csrPEM string, certSerial int64) error
GetChallengesByAuthorizationID(ctx context.Context, authorizationID uint) ([]*Challenge, error)
GetCertificatePEMByOrderID(ctx context.Context, accountID, orderID uint) (string, error)
GetChallengeByID(ctx context.Context, accountID, challengeID uint) (*Challenge, error)
// Update challenge handles updating the challenge status, and the authorization status as well as moving the order status.
UpdateChallenge(ctx context.Context, challenge *Challenge) (*Challenge, error)
}

View file

@ -0,0 +1,261 @@
package types
import (
"net/http"
)
const (
acmeErrorsURN = "urn:ietf:params:acme:error:"
// NOTE: ideally this would be a valid, dereferenceable link to human-readable documentation,
// but it's ok if it's not too. See https://datatracker.ietf.org/doc/html/rfc8555/#section-6.7
fleetCustomErrorsURI = "https://fleetdm.com/acme/error/"
)
// ACMEError represents an error related to the ACME protocol,
// see https://datatracker.ietf.org/doc/html/rfc8555/#section-6.7
//
// It renders as a problem document (https://datatracker.ietf.org/doc/html/rfc7807),
// a JSON object with specific fields. In particular for the ACME protocol,
// the type field is well-defined and corresponds to a specific error condition.
//
// This error type is handled by the domain-specific error encoder provided to
// encodeResponse.
type ACMEError struct {
Type string `json:"type"`
Title string `json:"title,omitempty"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
StatusCode int `json:"-"`
}
var (
enrollmentNotFound = EnrollmentNotFoundError("")
serverInternal = InternalServerError("")
)
func (e *ACMEError) ShouldReturnNonce() bool {
if e == nil {
return false
}
switch e.Type {
case enrollmentNotFound.Type, serverInternal.Type:
return false
default:
return true
}
}
func EnrollmentNotFoundError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "enrollmentNotFound",
Title: "The specified enrollment does not exist",
Detail: detail,
StatusCode: http.StatusNotFound,
}
}
func AccountDoesNotExistError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "accountDoesNotExist",
Title: "The request specified an account that does not exist",
Detail: detail,
StatusCode: http.StatusBadRequest, // as per RFC https://datatracker.ietf.org/doc/html/rfc8555/#section-7.3.1
}
}
func AccountRevokedError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "accountRevoked",
Title: "The request specified an account that is revoked",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
func TooManyAccountsError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "tooManyAccounts",
Title: "Too many accounts already exist for this enrollment",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
func TooManyOrdersError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "tooManyOrders",
Title: "Too many orders already exist for this account",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
// NOTE: surprisingly, the RFC does not document an error and status code for
// a POST-as-GET to an order URL with an ID that does not exist, so this is a
// Fleet custom error code.
func OrderDoesNotExistError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "orderDoesNotExist",
Title: "The request specified an order that does not exist",
Detail: detail,
StatusCode: http.StatusNotFound,
}
}
func OrderNotFinalizedError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "orderNotFinalized",
Title: "The request attempted to download a certificate for an order that is not finalized",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
func RejectedIdentifierError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "rejectedIdentifier",
Title: "The server will not issue certificates for the identifier",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
func UnsupportedIdentifierError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "unsupportedIdentifier",
Title: "An identifier is of an unsupported type",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
func BadNonceError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "badNonce",
Title: "The client sent an unacceptable anti-replay nonce",
Detail: detail,
StatusCode: http.StatusBadRequest, // as per RFC https://datatracker.ietf.org/doc/html/rfc8555/#section-6.5
}
}
func BadCSRError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "badCSR",
Title: "The CSR is unacceptable (e.g., due to a short key)",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
func InternalServerError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "serverInternal",
Title: "The server experienced an internal error",
Detail: detail,
StatusCode: http.StatusInternalServerError,
}
}
func BadPublicKeyError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "badPublicKey",
Title: "The JWS was signed by a public key the server does not support",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
func BadSignatureAlgorithmError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "badSignatureAlgorithm",
Title: "The JWS was signed with an algorithm the server does not support",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
func UnauthorizedError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "unauthorized",
Title: "The client lacks sufficient authorization",
Detail: detail,
StatusCode: http.StatusUnauthorized,
}
}
func MalformedError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "malformed",
Title: "The request message was malformed",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
// Custom Fleet error code, as RFC does not document what to return.
func AuthorizationDoesNotExistError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "authorizationDoesNotExist",
Title: "The specified authorization does not exist for the account",
Detail: detail,
StatusCode: http.StatusNotFound,
}
}
// Custom Fleet error code, as RFC does not document what to return.
func ChallengeDoesNotExistError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "challengeDoesNotExist",
Title: "The specified challenge does not exist for the authorization",
Detail: detail,
StatusCode: http.StatusNotFound,
}
}
func CertificateDoesNotExistError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "certificateDoesNotExist",
Title: "The order is finalized but the certificate does not exist for the order",
Detail: detail,
StatusCode: http.StatusNotFound,
}
}
func OrderNotReadyError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "orderNotReady",
Title: "The request attempted to finalize an order that is not ready to be finalized",
Detail: detail,
StatusCode: http.StatusForbidden, // as per RFC https://datatracker.ietf.org/doc/html/rfc8555/#section-6.7
}
}
func InvalidChallengeStatusError(detail string) *ACMEError {
return &ACMEError{
Type: fleetCustomErrorsURI + "invalidChallengeStatus",
Title: "The challenge is not in a valid status for the attempted operation",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
// Draft ACME device attest RFC https://datatracker.ietf.org/doc/html/draft-acme-device-attest-01#name-new-error-types
func BadAttestationStatementError(detail string) *ACMEError {
return &ACMEError{
Type: acmeErrorsURN + "badAttestationStatement",
Title: "The attestation statement provided by the client was unacceptable",
Detail: detail,
StatusCode: http.StatusBadRequest,
}
}
func (e *ACMEError) Error() string {
s := e.Type
if e.Title != "" {
s += ": " + e.Title
}
if e.Detail != "" {
s += ": " + e.Detail
}
return s
}

View file

@ -0,0 +1,28 @@
package types
import (
"crypto/rand"
"encoding/base64"
)
const nonceRawByteSize = 16 // 128 bits
// per the RFC (https://datatracker.ietf.org/doc/html/rfc8555/#section-6.5):
// > The precise method used to generate and track nonces is up to the
// > server. For example, the server could generate a random 128-bit
// > value
func CreateRawNonce() string {
nonce := make([]byte, nonceRawByteSize)
// as per rand.Read documentation, never returns an error and always fills the entire slice
_, _ = rand.Read(nonce)
return string(nonce)
}
// per the RFC (https://datatracker.ietf.org/doc/html/rfc8555/#section-6.5.1):
// > The value of the Replay-Nonce header field MUST be an octet string
// > encoded according to the base64url encoding described in Section 2 of
// > [RFC7515].
func CreateNonceEncodedForHeader() string {
nonce := CreateRawNonce()
return base64.RawURLEncoding.EncodeToString([]byte(nonce))
}

View file

@ -0,0 +1,47 @@
package acme
import (
"context"
"crypto/x509"
redigo "github.com/gomodule/redigo/redis"
)
// RedisPool is the minimal Redis pool interface needed by the ACME bounded context.
// fleet.RedisPool satisfies this implicitly via Go's structural typing.
type RedisPool interface {
Get() redigo.Conn
}
// CSRSigner signs x509 certificate requests. This is the ACME-specific
// interface for certificate signing, decoupled from the SCEP protocol types.
type CSRSigner interface {
SignCSR(ctx context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error)
}
// CSRSignerFunc is an adapter to allow use of ordinary functions as CSRSigner.
type CSRSignerFunc func(ctx context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error)
func (f CSRSignerFunc) SignCSR(ctx context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error) {
return f(ctx, csr)
}
// DataProviders combines all external dependency interfaces for the ACME
// bounded context. Methods are narrowed to exactly what ACME needs,
// keeping the bounded context decoupled from Fleet-internal types.
type DataProviders interface {
// ServerURL returns the base URL used to construct ACME endpoint URLs.
ServerURL(ctx context.Context) (string, error)
// GetCACertificatePEM returns the PEM-encoded root CA certificate
// used to build the certificate chain in download-certificate responses.
GetCACertificatePEM(ctx context.Context) ([]byte, error)
// CSRSigner returns the signer used to sign certificate requests
// during order finalization.
CSRSigner(ctx context.Context) (CSRSigner, error)
// IsDEPEnrolled reports whether the given serial number has an active
// DEP assignment, used during device attestation challenge validation.
IsDEPEnrolled(ctx context.Context, serial string) (bool, error)
}

View file

@ -0,0 +1,142 @@
package testhelpers
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"fmt"
"time"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/service"
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
"github.com/fxamacker/cbor/v2"
)
// GenerateTestKey generates an ECDSA P-256 key pair and returns the private key and public JWK.
func GenerateTestKey() (*ecdsa.PrivateKey, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
return key, err
}
func GenerateTestAttestationCA() (*x509.Certificate, *ecdsa.PrivateKey, error) {
key, err := GenerateTestKey()
if err != nil {
return nil, nil, fmt.Errorf("failed to generate test key: %w", err)
}
template := &x509.Certificate{
Subject: pkix.Name{CommonName: "Test Attestation CA"},
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
IsCA: true,
BasicConstraintsValid: true,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, nil, fmt.Errorf("failed to create test attestation CA certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, nil, fmt.Errorf("failed to parse test attestation CA certificate: %w", err)
}
return cert, key, nil
}
func BuildAttestationLeafCert(ca *x509.Certificate, caKey *ecdsa.PrivateKey, serial, token string) (*x509.Certificate, error) {
template := &x509.Certificate{
Subject: pkix.Name{CommonName: "Test Attestation Leaf"},
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
BasicConstraintsValid: true,
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
}
hashedNonce := sha256.Sum256([]byte(token))
template.ExtraExtensions = []pkix.Extension{
{
Id: service.OIDAppleSerialNumber,
Value: []byte(serial),
},
{
Id: service.OIDAppleNonce,
Value: hashedNonce[:],
},
}
key, err := GenerateTestKey()
if err != nil {
return nil, fmt.Errorf("failed to generate key for attestation leaf cert: %w", err)
}
certDER, err := x509.CreateCertificate(rand.Reader, template, ca, key.Public(), caKey)
if err != nil {
return nil, fmt.Errorf("failed to create attestation leaf certificate: %w", err)
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, fmt.Errorf("failed to parse attestation leaf certificate: %w", err)
}
return cert, nil
}
// The leaf cert should always be the first
func BuildAppleDeviceAttestationPayload(certs ...*x509.Certificate) (any, error) {
x5c := make([][]byte, len(certs))
for i, cert := range certs {
x5c[i] = cert.Raw
}
appleAttest := types.AppleDeviceAttestationStatement{
X5C: x5c,
}
appleAttestCbor, err := cbor.Marshal(appleAttest)
if err != nil {
return nil, fmt.Errorf("failed to marshal Apple device attestation statement to CBOR: %w", err)
}
attObj := types.AttestationObject{
Format: "apple",
AttestationStatement: appleAttestCbor,
}
attObjCbor, err := cbor.Marshal(attObj)
if err != nil {
return nil, fmt.Errorf("failed to marshal attestation object to CBOR: %w", err)
}
base64Encoded := base64.RawURLEncoding.EncodeToString(attObjCbor)
// finally embed in the top-level json payload
return struct {
AttObj string `json:"attObj"`
}{
AttObj: base64Encoded,
}, nil
}
// generateCSRDER creates a base64 URL encoded DER-encoded ECDSA CSR with the given common name.
func GenerateCSRDER(commonName string) (string, *ecdsa.PrivateKey, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return "", nil, fmt.Errorf("failed to generate key for CSR: %w", err)
}
template := &x509.CertificateRequest{
Subject: pkix.Name{
CommonName: commonName,
},
}
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
if err != nil {
return "", nil, fmt.Errorf("failed to create CSR: %w", err)
}
// base64 URL encode the DER csr as per the RFC 7.4 spec
encoded := base64.RawURLEncoding.EncodeToString(csrDER)
return encoded, key, nil
}

Some files were not shown because too many files have changed in this diff Show more